System Design
D3

Data modeling: a schema from the access patterns

Build a schema in five steps: entities, access patterns, keys and indexes, normalise or denormalise, then choose the store. Two worked examples, a multi-tenant SaaS and a social feed, apply the method.

Not startedSaved in this browser only.
  1. 1List the access patterns first: each query, its rate and its latency target. Keys and indexes follow from that list.
  2. 2Normalise by default. Copy data into a read model only for a hot read that joins many rows.
  3. 3Put the owner of the data first in every key: tenant_id, user_id. Queries, security and later shards all use it.
  4. 4Choose types on purpose: time-ordered IDs, timestamptz, exact money, and JSONB only for attributes no query joins on.
D3
    A

    The method

    five steps, each leaves an artifact
    the method: each step leaves an artifact on the board1Entities

    What are the nouns and how do they relate?

    tenant 1 : n useruser n : m roletenant 1 : n audit row
    2Access patterns

    Which queries, how often, how fast?

    Q1 sign in by email 200/s · p99 50 msQ6 audit page, newest 5/s · p99 200 ms
    3Keys, indexes

    Which key or index answers each query?

    Q1: UQ (tenant_id, lower(email))Q6: PK (tenant_id, at, id)
    4Shape

    Normalise, or copy data for a hot read?

    normalise by defaultcopy only for a hotread with a big fan-in
    5Store

    Which store holds the truth, and which holds copies?

    truth: Postgrescopies: cache, search,rebuilt from the truth
    a new query, or a rate that grows, restarts at step 2
    • Step 2 drives the rest. Write each query as a sentence with a number: "the 50 newest posts for one reader, 2,300 a second, p99 under 100 ms".
    • Step 3: one index range answers one query. The leading key columns are the columns the query names with "=".
    • Step 4: copy data only when a hot read would join many rows. Every copy needs a writer that keeps it current.
    • Step 5: one store holds the truth. Caches, search indexes and timelines are copies that you can rebuild.

    I list the entities, then every query with its rate and latency target. Each query gets a key or an index, and only then do I decide what to copy and where it lives.

    B

    Five data models

    what each is built to answer
    modelstoresbuilt forstatusexample
    RelationalRows in tables, joined by keysAny query, joins, constraints, transactionsDefaultPostgres
    DocumentOne nested record per keyRead or write a whole recordWhole-record accessMongoDB JSONB
    Wide-columnPartitions of rows sorted by a clustering keyOne partition, a range of its rowsKnown queries, huge writesCassandra
    Key-valueOne value per keyGet and put by keyLookups by key onlyRedis DynamoDB
    GraphNodes and edgesTraverse many hops from one nodeDeep traversalsNeo4j
    Entity-attribute-value rowsOne row per attributeNothing wellNot approvedUse JSONB instead

    A document store and a wide-column store answer only the queries the model was built for. A new query often means a new copy of the data.

    Relational is my default because it answers queries I did not plan for. I pick another model when one known query shape dominates and outgrows one machine.

    C

    Model per query

    wide-column
    one write, two tables: each query reads one partitionmessages_by_conversationQ1: a conversation, newest firstpartition key conversation_id · clustering key sent_at DESCconv 710:42 ann ok10:41 bob lunch?10:30 ann hiconv 909:15 cy done09:02 ann status?messages_by_senderQ2: what one user sentpartition key sender_id · clustering key sent_at DESCann10:42 conv 7 ok10:30 conv 7 hi09:02 conv 9 status?bob10:41 conv 7 lunch?
    • The partition key picks one node and one partition.
    • The clustering key sorts rows inside it, so a range read is sequential.
    • No joins: a second query gets a second table.

    In a wide-column store I write one table per query. The partition key is what the query names; the clustering key is the order it reads.

    D

    SaaS: entities and access patterns

    example 1, steps 1 to 3
    multi-tenant SaaS: tenant_id leads every keyn : 11 : n1 : n1 : n1 : n1 : nplansPKcodelookupseatsprice_centsbigintCHECK price_cents ≥ 0tenantsPKididentityUQslugFKplancreated_attimestamptzrolesPK FKtenant_idPKidnameUQ (tenant_id, name)audit_logPKtenant_idPKattimestamptzPKidactionenumtargetdiffjsonbpartitioned by monthusersPK FKtenant_idPKidUUIDv7emailattrsjsonbcreated_atdeleted_atsoft deleteUQ live email per tenantuser_rolesPKtenant_idPK FKuser_idPK FKrole_idboth FKs carry tenant_id
    #access patternper secondp99served by
    Q1Sign in: user by email in a tenant20050 msUQ (tenant_id, lower(email))
    Q2Check a user's roles, every request2,0005 msPK of user_roles, prefix
    Q3List the admins of a tenant5020 msidx (tenant_id, role_id)
    Q4Find users by an attribute10100 msGIN on attrs
    Q5Write an audit row per change50020 msappend to this month
    Q6Audit page, newest first5200 msPK (tenant_id, at, id)

    Rates and targets are example requirements for the interview, not measurements. Q2 at 2,000 a second is the hot path; cache the role set per session if it grows.

    Every access pattern in this product names a tenant, so tenant_id leads every key. That one choice serves the queries, the security policy and a later shard.

    E

    SaaS: the schema, and what it refuses

    tested in the lab; refusals recorded from Postgres
    tenants, users, roles, auditsql
    -- A lookup table: the business edits plans, and a plan has attributes.
    CREATE TABLE plans (
      code        text   PRIMARY KEY,
      seats       int    NOT NULL,
      price_cents bigint NOT NULL CHECK (price_cents >= 0)1
    );
    
    CREATE TABLE tenants (
      id         bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      slug       text        NOT NULL UNIQUE,
      plan       text        NOT NULL REFERENCES plans (code),
      created_at timestamptz NOT NULL DEFAULT now()
    );
    
    CREATE TABLE users (
      tenant_id  bigint      NOT NULL REFERENCES tenants (id),
      id         uuid        NOT NULL,
      email      text        NOT NULL,
      attrs      jsonb       NOT NULL DEFAULT '{}',
      created_at timestamptz NOT NULL DEFAULT now(),
      deleted_at timestamptz,
      PRIMARY KEY (tenant_id, id)2
    );
    -- One live account per email in a tenant. A deleted row does not block a new one.
    CREATE UNIQUE INDEX users_email ON users (tenant_id, lower(email))
      WHERE deleted_at IS NULL3;
    CREATE INDEX users_attrs ON users USING gin (attrs jsonb_path_ops4);
    
    CREATE TABLE roles (
      tenant_id bigint NOT NULL REFERENCES tenants (id),
      id        bigint GENERATED ALWAYS AS IDENTITY,
      name      text   NOT NULL,
      PRIMARY KEY (tenant_id, id),
      UNIQUE (tenant_id, name)
    );
    
    -- Many-to-many. Both keys carry tenant_id, so a link can never cross tenants.
    CREATE TABLE user_roles (
      tenant_id bigint NOT NULL,
      user_id   uuid   NOT NULL,
      role_id   bigint NOT NULL,
      PRIMARY KEY (tenant_id, user_id, role_id),
      FOREIGN KEY (tenant_id, user_id) REFERENCES users (tenant_id, id),
      FOREIGN KEY (tenant_id, role_id) REFERENCES roles (tenant_id, id)5
    );
    CREATE INDEX user_roles_role ON user_roles (tenant_id, role_id);
    
    -- An enum: a short, fixed set that only a code change extends.
    CREATE TYPE audit_action AS ENUM6 ('create', 'update', 'delete', 'login');
    
    -- Append-only, one partition per month. Retention drops a partition.
    CREATE TABLE audit_log (
      tenant_id bigint       NOT NULL,
      at        timestamptz  NOT NULL DEFAULT now(),
      id        bigint       GENERATED ALWAYS AS IDENTITY,
      actor_id  uuid,
      action    audit_action NOT NULL,
      target    text         NOT NULL,
      diff      jsonb        NOT NULL DEFAULT '{}',
      PRIMARY KEY (tenant_id, at, id)
    ) PARTITION BY RANGE (at)7;
    CREATE TABLE audit_log_2026_10 PARTITION OF audit_log
      FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
    CREATE TABLE audit_log_2026_11 PARTITION OF audit_log
      FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
    1. 1Money as an integer count of cents. Exact sums, and the database refuses a negative price.
    2. 2tenant_id first: every index range is one tenant, and other tables can reference the pair.
    3. 3A partial unique index. Only live rows must be unique, so a deleted user can sign up again.
    4. 4A GIN index for containment (@>). It answers "attrs contains this pair" without a scan.
    5. 5Both sides carry tenant_id. A link to another tenant's role cannot exist.
    6. 6An enum for a fixed set. Postgres can add a value later, but not remove one.
    7. 7One partition per month. Retention drops a partition; no row-by-row DELETE.
    attemptresult
    Tenant 1 writes a row for tenant 2Refused, 42501
    A link joins a tenant 1 user to a tenant 2 roleRefused, 23503
    A second live account with the same email, other caseRefused, 23505
    The service changes an audit rowRefused, 42501
    A plan price below zeroRefused, 23514
    A request names no tenantSees 0 rows
    A deleted user signs up againAccepted
    row-level securitysql
    -- Every tenant table filters by the tenant the transaction names. No tenant named: no rows.
    ALTER TABLE users ENABLE ROW LEVEL SECURITY;
    CREATE POLICY tenant ON users
      USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')1::bigint);
    -- ... the same policy on roles, user_roles, audit_log
    GRANT SELECT, INSERT, UPDATE, DELETE ON users, roles, user_roles TO modeling_app;
    GRANT SELECT ON plans, tenants TO modeling_app;
    -- The service can add audit rows and read them, never change them.
    GRANT SELECT, INSERT ON audit_log TO modeling_app2;
    1. 1The tenant comes from the transaction. Unset, it is NULL, and NULL matches no row.
    2. 2No UPDATE or DELETE: "permission denied for table audit_log".

    The table owner bypasses these policies. The service connects as its own role.

    Keys carry the tenant and the email index skips deleted rows. Row-level security filters every query by the tenant, so a query that forgets it sees nothing.

    F

    Feed: entities and access patterns

    example 2, steps 1 to 3
    social feed: three truth tables, one derived read modeln : m1 : nreaderpost_idfan-outusersPKidUQhandlefollowsPK FKfollower_idPK FKfollowee_idreverse idx: followee firstpostsPKidtime-orderedFKauthor_idcreated_atbodyidx (author_id, created_at)timelinederivedPKuser_idPKcreated_atPKpost_idauthor_idone row per reader per post
    #access patternper secondserved by
    F1Home feed: 50 newest posts of everyone I follow2,300PK (user_id, created_at, post_id)
    F2Publish a post60posts PK, then fan-out
    F3Who follows this author (for fan-out)60idx (followee_id, follower_id)
    F4One author's posts, newest first300idx (author_id, created_at)
    F5Follow or unfollow20follows PK
    reads
    10 million daily users × 20 feed opens = 200 million a day ≈ 2,300 a second.
    writes
    10 million users × 0.5 posts a day = 5 million a day ≈ 60 a second.
    ratio
    About 40 reads per post: do the work once at write time.
    users, follows, posts, timelinesql
    CREATE TABLE users (
      id     bigint PRIMARY KEY,
      handle text   NOT NULL UNIQUE
    );
    
    -- Many-to-many between users. The key answers "whom do I follow".
    CREATE TABLE follows (
      follower_id bigint NOT NULL REFERENCES users (id),
      followee_id bigint NOT NULL REFERENCES users (id),
      PRIMARY KEY (follower_id, followee_id)1
    );
    -- The reverse index answers "who follows me", which fan-out needs.
    CREATE INDEX follows_followee ON follows (followee_id, follower_id);2
    
    CREATE TABLE posts (
      id         bigint      PRIMARY KEY,
      author_id  bigint      NOT NULL REFERENCES users (id),
      created_at timestamptz NOT NULL,
      body       text        NOT NULL
    );
    CREATE INDEX posts_author_time ON posts (author_id, created_at DESC);3
    
    -- The read model: one row per (reader, post). Written at post time, read in one range scan.
    CREATE TABLE timeline (
      user_id    bigint      NOT NULL,
      created_at timestamptz NOT NULL,
      post_id    bigint      NOT NULL,
      author_id  bigint      NOT NULL,
      PRIMARY KEY (user_id, created_at, post_id)4
    );
    1. 1A many-to-many link. The key answers "whom do I follow" with one range.
    2. 2The reverse direction. Fan-out reads it to find every follower of an author.
    3. 3One author's posts, newest first, for profiles and for the pull side of the hybrid.
    4. 4The read model. One range scan, newest first, returns a feed page. post_id breaks ties.

    The hot query is the home feed: the 50 newest posts from everyone I follow. I give it its own table, keyed by reader and time.

    G

    Normalise or denormalise

    fan-out on write, on read, or both
    one post is writtenone feed is readon writepushauthorT1T2T3Tnn timeline rowsTreader1 range scan, 50 rowson readpullauthorpost1 post rowA1A2A3Akreaderk authors, then sorthybridpush most, pull starsauthorT1T2Tnn rows, or 1 for a starTstarreadertimeline + star posts
    post, read, trimpseudo code
    post(author, body):
      BEGIN
        insert the post                               // the truth
        FOR EACH follower OF author:                  // one INSERT ... SELECT1
          add (follower, created_at, post) to timeline
      COMMIT                                          // rows written = followers2
    
    read_feed(reader):
      ids = 50 newest timeline rows of reader         // one index range3
      RETURN the posts of ids                         // 50 lookups by primary key
    
    trim(reader, keep):
      delete timeline rows of reader older than the newest keep4
    1. 1The fan-out is one statement: read the followers from the reverse index, write one timeline row each.
    2. 2The cost moves to the writer. 10,001 followers took 41 ms; 100,001 took 497 ms.
    3. 3Constant work: 100 rows read, whether the reader follows 10 authors or 5,000.
    4. 4A cap keeps each timeline bounded. Older pages fall back to the join, which is rare.
    Tested source Go: post and fan out · SQL: fan-out, read, trim
    Go: post and fan outgo
    
    // Post writes a post and fans it out to every follower's timeline, in one transaction. It
    // returns the number of timeline rows written.
    func Post(ctx context.Context, db *pgxpool.Pool, id, author int64, at time.Time, body string) (int64, error) {
      tx, err := db.Begin(ctx)
      if err != nil {
        return 0, fmt.Errorf("begin: %w", err)
      }
      if _, err := tx.Exec(ctx, Q("feed", "post"), id, author, at, body); err != nil {
        return 0, fmt.Errorf("insert post: %w", rollback(ctx, tx, err))
      }
      tag, err := tx.Exec(ctx, Q("feed", "fan_out"), id, author, at)
      if err != nil {
        return 0, fmt.Errorf("fan out: %w", rollback(ctx, tx, err))
      }
      if err := tx.Commit(ctx); err != nil {
        return 0, fmt.Errorf("commit: %w", err)
      }
      return tag.RowsAffected(), nil
    }
    
    SQL: fan-out, read, trimsql
    -- One row per follower of the author. The cost grows with the follower count.
    INSERT INTO timeline (user_id, created_at, post_id, author_id)
    SELECT f.follower_id, $3, $1, $2 FROM follows f WHERE f.followee_id = $2;
    
    -- Denormalised: one range scan of the reader's timeline, then 50 lookups by primary key.
    SELECT p.id, p.author_id, p.created_at, p.body
    FROM timeline t
    JOIN posts p ON p.id = t.post_id
    WHERE t.user_id = $1
    ORDER BY t.created_at DESC, t.post_id DESC
    LIMIT 50;
    
    -- Keep the newest $2 rows per reader, so a timeline never grows without bound.
    DELETE FROM timeline t
    WHERE t.user_id = $1
      AND (t.created_at, t.post_id) < (
        SELECT created_at, post_id FROM timeline
        WHERE user_id = $1
        ORDER BY created_at DESC, post_id DESC
        OFFSET $2 - 1 LIMIT 1);
    designstatuscost
    Join on every readFew follows, low rateAt 1,000 follows: 202,000 rows and 25 ms per read.
    Fan-out on write to a timelineApprovedOne row per follower per post. Reads cost 0.20 ms.
    Hybrid: push most authors, pull authors with very many followersLarge audiencesA read merges the timeline with a few authors' newest posts.
    Copy the post body into each timeline rowNot approvedAn edit must rewrite every copy. Store the post ID only.
    Timeline with no capNot approvedGrows by one row per followed post, forever.

    I write a timeline row per follower when the post is made. Each read is then one range scan; in the lab that was 127 times faster than the join at 1,000 follows.

    H

    See it: three experiments

    recorded from the lab Postgres
    reader follows
    median read of the 50 newest posts, log scale0.1110100normalised join25 ms202,000 rows readtimeline table0.20 ms100 rows read

    Following 1,000 authors, the join read 202,000 rows and took 25 ms. The timeline read 100 rows and took 0.20 ms. The join was 127 times slower.

    5,000 authors with 40 posts each. Median of 200 runs from one client. With 32 clients at 1,000 follows: 199 joins a second against 29,703 timeline reads.

    Postgres 16.14 on one 16-thread laptop, shared with other runs. Read the numbers as ratios and orders of magnitude.

    The join grows with the number of follows and the timeline does not. Random UUIDs cost WAL and index space, and a closure table trades storage and move cost for fast subtree reads.

    I

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresComposite primary keys and B-tree indexesThe leading columns pick one range: one tenant, one reader, newest first.Every access pattern
    PostgresComposite foreign keysA link must match the tenant on both sides.Any owned child row
    PostgresPartial and expression indexesUnique email per tenant, ignoring case and deleted rows.Soft deletes, "one active per user"
    PostgresRow-level security policiesEvery query filters by the transaction's tenant, even one with a bug.Per-user data, shared tables
    PostgresGRANT per table and roleThe service can append audit rows and never change them.Ledgers, event logs
    PostgresJSONB with a GIN indexFlexible attributes that a containment query can still find.Settings, product attributes
    PostgresDeclarative partitioning by rangeAudit retention by dropping a month.Logs, events, time series
    PostgresWITH RECURSIVEWalk an adjacency list up or down in one query.Org charts, threads, bills of materials
    PostgresINSERT ... SELECTFan-out to every follower in one statement.Backfills, copies between tables
    Postgresnumeric, timestamptzExact money; instants that do not depend on the session time zone.Billing, scheduling
    PostgresEXPLAIN (ANALYZE, BUFFERS, WAL)Rows, pages and WAL bytes of one query: the numbers on this sheet.Index reviews, capacity checks
    CassandraPartition key plus clustering keyOne table per query; rows sorted inside a partition.Messages, events per device
    DynamoDBPartition key plus sort key; global secondary indexesThe same model, managed. Reads from a global secondary index are always eventually consistent.Carts, sessions at large scale

    Postgres gives me composite keys, partial and expression indexes, row-level security, JSONB with GIN, partitions and recursive queries. That covers most schemas before any second store.

    J

    Hierarchies

    three ways to store a tree
    one tree, three tables; shaded: the subtree of node 21234567adjacency listidparent_id1NULL2131425263737 rows; one step per levelmaterialised pathidpath1/1/2/1/2/4/1/2/4/5/1/2/5/3/1/3/6/1/3/6/7/1/3/7/7 rows, in path order; one rangeclosure tableancestordescendantdepth11012114222024125144017 rows in all; one range
    modelread a subtreemove a subtreestoragefits
    Adjacency listStep per level
    117 ms
    1 row
    0.94 ms
    26.4 MBFrequent moves, shallow reads
    Materialised pathOne range
    24 ms
    4,681 rows
    54 ms
    53.3 MBCategories, folders, breadcrumbs
    Closure tableOne range
    11 ms
    18,724 rows
    310 ms
    235.2 MBRead-heavy trees, depth queries
    subtree and move, three wayspseudo code
    subtree(n), adjacency list:
      rows = {n}
      REPEAT: rows += children of the last level   // one index scan per level1
      UNTIL a level adds nothing
    
    subtree(n), materialised path:
      p = path of n                                // '/1/2/'
      RETURN rows WHERE p <= path < p + '~'2        // one index range
    
    subtree(n), closure table:
      RETURN descendants WHERE ancestor = n3        // one index range, too
    
    move(n, under m):
      adjacency: set parent of n to m              // 1 row
      path:      rewrite the prefix of every row in the subtree4
      closure:   delete (old ancestor, node) pairs
                 insert (new ancestor, node) pairs5
    1. 1A recursive query: one round per level, each an index scan on parent_id.
    2. 2In byte order, every path with this prefix sits in one contiguous index range.
    3. 3The table stores every ancestor pair, so the subtree is already listed.
    4. 44,681 rows for a subtree of 4,681 nodes.
    5. 5Subtree size × (old + new ancestors): 18,724 rows in the lab.
    Tested source SQL: subtree, three ways · SQL: move, three ways
    SQL: subtree, three wayssql
    -- adjacency list
    -- Walk down one level per step.
    WITH RECURSIVE sub AS (
      SELECT id FROM cat_adjacency WHERE id = $1
      UNION ALL
      SELECT c.id FROM cat_adjacency c JOIN sub ON c.parent_id = sub.id
    )
    SELECT id FROM sub ORDER BY id;
    
    -- materialised path
    -- One range scan: every path that starts with the node's path. '~' sorts after '/' and digits.
    SELECT c.id
    FROM cat_path c, (SELECT path FROM cat_path WHERE id = $1) s
    WHERE c.path >= s.path AND c.path < s.path || '~'
    ORDER BY c.id;
    
    -- closure table
    -- One index range: every descendant of $1.
    SELECT descendant_id AS id FROM cat_closure WHERE ancestor_id = $1 ORDER BY id;
    SQL: move, three wayssql
    -- Move node $1 under $2: one row changes.
    UPDATE cat_adjacency SET parent_id = $2 WHERE id = $1;
    
    -- Every row in the subtree gets a new prefix.
    UPDATE cat_path c
    SET path = (SELECT path FROM cat_path WHERE id = $2) || substr(c.path, length(m.parent_path) + 1)
    FROM (SELECT path, left(path, length(path) - length(id::text) - 1) AS parent_path
          FROM cat_path WHERE id = $1) m
    WHERE c.path >= m.path AND c.path < m.path || '~';
    
    -- Remove the links from the old ancestors to every node in the subtree.
    DELETE FROM cat_closure c
    USING cat_closure sub, cat_closure up
    WHERE sub.ancestor_id = $1
      AND up.descendant_id = $1 AND up.ancestor_id <> $1
      AND c.ancestor_id = up.ancestor_id AND c.descendant_id = sub.descendant_id;
    
    -- Link every new ancestor to every node in the subtree.
    INSERT INTO cat_closure (ancestor_id, descendant_id, depth)
    SELECT up.ancestor_id, sub.descendant_id, up.depth + sub.depth + 1
    FROM cat_closure up, cat_closure sub
    WHERE up.descendant_id = $2 AND sub.ancestor_id = $1;

    Tree of 299,593 nodes, 8 children each. Subtree: 37,449 nodes. Move: 4,681 nodes. The closure table holds 2,054,353 rows. Postgres also ships ltree, a path type with its own operators.

    An adjacency list moves a subtree by changing one row. A path or a closure table reads a subtree as one range but rewrites rows on every move. I choose by which operation is frequent.

    K

    IDs

    index locality
    leaf pages of the primary key index; marks: the next insertsUUIDv4randomleaves 73% full · 4,149 page images in 10,000 insertsUUIDv7, biginttime-orderedleaves 90% full · 3 page images in 10,000 inserts
    IDbytesmade bystatus
    bigint identity8The databaseOne database
    Snowflake: time, worker, sequence8Any process with a worker numberApproved
    UUIDv716Anyone, no coordinationApproved
    UUIDv4 as the primary key16AnyoneLow insert rate
    Sequential IDs in public URLs8The databaseNot approved
    three generatorspseudo code
    uuid_v4():   122 random bits                      // lands anywhere
    uuid_v7():   48-bit Unix milliseconds, then random   // sorts by time1
    snowflake(): 41 bits ms | 10 bits worker | 12 bits sequence2
      IF 4,096 IDs used in this ms: wait for the next ms
    1. 1The time comes first, so a later ID sorts later, byte by byte (RFC 9562).
    2. 24,096 IDs per millisecond per worker, by construction.
    Tested source Go: ID generators
    Go: ID generatorsgo
    
    // NewV4 returns a random UUID (version 4): 122 random bits. Consecutive IDs land at random
    // places in a B-tree index.
    func NewV4(r *rand.Rand) UUID {
      var u UUID
      binary.BigEndian.PutUint64(u[0:8], r.Uint64())
      binary.BigEndian.PutUint64(u[8:16], r.Uint64())
      u[6] = u[6]&0x0f | 0x40 // version 4
      u[8] = u[8]&0x3f | 0x80 // variant 10
      return u
    }
    
    // NewV7 returns a time-ordered UUID (version 7, RFC 9562): a 48-bit Unix time in milliseconds,
    // then random bits. IDs made later sort later, so inserts go to the right edge of the index.
    func NewV7(r *rand.Rand, now time.Time) UUID {
      var u UUID
      ms := uint64(now.UnixMilli())
      binary.BigEndian.PutUint64(u[0:8], ms<<16|r.Uint64()&0xffff)
      binary.BigEndian.PutUint64(u[8:16], r.Uint64())
      u[6] = u[6]&0x0f | 0x70 // version 7
      u[8] = u[8]&0x3f | 0x80 // variant 10
      return u
    }
    
    // Snowflake makes 64-bit IDs: 41 bits of milliseconds since Epoch, 10 bits of worker number,
    // 12 bits of sequence within one millisecond. Each process needs its own worker number.
    type Snowflake struct {
      Epoch  time.Time
      Worker int64 // 0 to 1023
      lastMS int64
      seq    int64
    }
    
    // Next returns the next ID at time now. It returns false when the 4,096 IDs of this millisecond
    // are used up; the caller waits for the next millisecond.
    func (s *Snowflake) Next(now time.Time) (int64, bool) {
      ms := now.Sub(s.Epoch).Milliseconds()
      if ms == s.lastMS {
        s.seq++
        if s.seq > 0xfff {
          return 0, false
        }
      } else {
        s.lastMS, s.seq = ms, 0
      }
      return ms<<22 | s.Worker<<12 | s.seq, true
    }
    

    Index after 1,000,000 rows: bigint 21.4 MB, UUIDv7 30.1 MB, UUIDv4 37.2 MB. Sequential IDs reveal your volume and invite guessing; expose a UUID instead.

    I use a time-ordered ID: UUIDv7 when clients or many services make IDs, a bigint when one database does. Random UUIDv4 keys wrote 18 times the WAL per insert in the lab.

    L

    Relationships and columns

    patterns, approved or not
    needpatternstatus
    One-to-manyFK on the many side, with an indexApproved
    Many-to-manyLink table; PK both ways plus a reverse indexApproved
    "Belongs to one of several tables"One nullable FK per parent, CHECK exactly oneApproved
    "Belongs to one of several tables"parent_type plus parent_idNot approved
    Soft deletedeleted_at, partial unique indexesEvery query filters it
    Timestampscreated_at, updated_at as timestamptzApproved
    An instant in timetimestamp without time zoneNot approved
    Moneybigint minor units, or numericApproved
    Moneyfloat8, realNot approved
    A fixed set of statesenumSet changes with code
    A set the business editslookup table with an FKApproved
    Attributes that vary by rowJSONB with a GIN indexNot joined or constrained
    float8
    0.10 + 0.20 = 0.30000000000000004; equal to 0.30: false.
    numeric
    0.10 + 0.20 = 0.30; equal to 0.30: true.
    timestamptz
    Written as 09:00 in Kolkata, read in Los Angeles: 2026-10-04 20:30:00-07. Same instant.
    timestamp
    Same write, same read: 2026-10-05 09:00:00. A different instant.

    Recorded from Postgres. A parent_type column cannot carry a foreign key, so the database cannot check the link.

    I index every foreign key and use link tables for many-to-many. Instants are timestamptz, money is an integer or numeric, and JSONB holds only attributes no query joins on.

    M

    Failure cases

    what breaks, and what the model does about it
    eventresultwhy it stays correct, or the fixsaved by
    N+1: load 50 posts one query each51 round trips: 8.8 ms against 0.53 ms for one query, on one machine.Fetch the 50 posts in one query with a join or an IN list. On a network every round trip adds latency.One query
    A reader follows 5,000 authorsThe timeline grows by every followed post, without bound.Trim each timeline to the newest few hundred rows. Older pages use the join.Trim
    An author with millions of followers postsMillions of timeline rows for one post: 100,001 took 497 ms.Do not fan out large authors. Merge their newest posts at read time.Hybrid
    A partition key with few values or one hot valueOne partition takes most writes: a date, a country, one big tenant.Lead with a high-cardinality owner (tenant_id, user_id); move a huge tenant to its own shard.Key choice
    A query forgets the tenant filterWithout a policy it reads every tenant.Row-level security filters by the transaction's tenant. No tenant set: 0 rows.RLS
    Random UUID primary keys at a high insert rate2,674 WAL bytes per row against 151; leaves 73% full.Use UUIDv7 or a bigint. More WAL also means more replica traffic.UUIDv7
    A price stored as floatSums drift: 0.30000000000000004.bigint cents or numeric.numeric
    A closure table subtree moves18,724 rows rewritten in one transaction: 310 ms.If moves are frequent, use an adjacency list or a path.Model choice
    A post is edited after fan-outTimelines hold only the post ID.The read looks the post up by key, so it always shows the current body.IDs only
    N

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Normalised2+ read model3+ partitions, caps4+ timeline cache5+ shards by ownermore load →
    Feed reads against demand1001k10k100kDemand, average: 2,315 reads of the 50 newest posts per secondDemand, average2,315Demand, 5× peak: 11,574 reads of the 50 newest posts per secondDemand, 5× peak11,574Join, 1,000 follows: 199 reads of the 50 newest posts per secondJoin, 1,000 follows199Timeline table: 29,703 reads of the 50 newest posts per secondTimeline table29,703reads of the 50 newest posts per second, log scale
    stepaddit handlesmove up when you see
    1One Postgres, normalised, one index per access pattern.Every query, with joins. The feed join served 199 reads a second at 1,000 follows.A hot read joins many rows, and its latency grows with the data.
    2A read model in Postgres: the timeline, written by fan-out.29,703 feed reads a second in the lab, flat as follows grow.Tables grow so large that retention and index size become work.
    3Partitions and caps: audit by month, timelines trimmed.Retention by dropping a partition; bounded rows per reader.Feed reads take most of the primary's CPU.
    4A timeline cache: a capped sorted set per active reader in Redis.Hot feeds from memory; Postgres keeps the truth and rebuilds a lost set.Writes or storage pass one primary.
    5Shards by the owner key: tenant_id or user_id.Every access pattern already names the key, so each query stays on one shard.Top of the ladder.

    Demand example: 10 million daily users × 20 feed opens = 200,000,000 / 86,400 ≈ 2,315 a second. Capacity measured in the lab with 32 clients; read it as an order of magnitude.

    I start normalised on one Postgres with an index per access pattern. I add a read model only for a hot read that joins many rows. I shard last, by the key that already leads every table.

    O

    Drill

    predict, then reveal

    0 of 10 known

    1. Why list the access patterns before you draw any table?

    2. Why put tenant_id first in every primary key of a SaaS schema?

    3. A user is soft deleted, then signs up again with the same email. What fails, and what is the fix?

    4. The home feed takes 25 ms for a user who follows 1,000 authors. Why, and what do you change?

    5. An author with 10 million followers posts. What does fan-out on write cost?

    6. Why does a random UUID primary key cost more than a time-ordered one?

    7. When do you choose a closure table over a materialised path?

    8. How do you store a price?

    9. Enum or lookup table for an order status?

    10. When does a wide-column store fit chat messages better than Postgres?

    P

    Numbers to say

    measured, derived or cited
    feed join
    25 ms at 1,000 follows; 0.82 ms at 10. Grows with follows.
    timeline
    0.20 ms, 100 rows read, at any follow count.
    reads/s
    199 joins against 29,703 timeline reads, 32 clients.
    fan-out
    100,001 timeline rows in about 497 ms.
    N+1
    51 queries 8.8 ms; one query 0.53 ms.
    UUIDv4
    2,674 WAL bytes per insert against 151 for UUIDv7; index 1.7× a bigint's.
    closure
    6.9 rows per node at depth 6.
    snowflake
    4,096 IDs per ms per worker (12-bit sequence).

    Postgres 16 on an 8-core laptop shared with other runs; latency is the median of 200 runs from one client. A server that flushes each commit to durable storage writes slower.