Skip to content

Fundamentals

Databases

What shape to give data and where to keep it, from relational tables to documents, wide-column, graph, search, and vector stores.

Concise article — AI can help

Copy this prompt and paste it into your AI chat:

Help me study this article for practical system design.
Article: https://tech0ver.github.io/sysdes/fundamentals/databases.md
Ask my goal and preferred depth: a quick recap, a walkthrough, or a deeper discussion. Use the article examples, flag your additions, and let me set the pace.

Or give your AI the article link with your own prompt:

Every system design says where each kind of data lives, and that choice decides which queries are cheap, which rules the data can enforce, and how many copies must be kept in sync. To defend it, you need to know the shapes data can take, the store that fits each shape, and when a second store is worth its cost.

A photo-sharing network starts with users, posts, comments, likes, and follows. Before you pick a store, list what the app asks and what must stay correct:

  • open a post with its author’s name;
  • page through a post’s comments in order;
  • show whom a user follows and who follows them;
  • a user likes a post at most once;
  • a follow goes one way: following someone does not make them follow back;
  • deleting an account removes its likes and follows.

The relational model fits this list. Data lives in tables of rows, and each row has a primary keyPrimary keyThe column or columns that identify each record of a table. No two records share it. The database keeps an index on it to find records by it.Read more: Wikipedia: Primary key. A row refers to another row by storing that row’s key in a foreign key column. A one-to-many relation needs only this column: a post stores author_id, a comment stores post_id. A many-to-many relation, such as likes, gets its own table that joins the two sides:

CREATE TABLE likes (
user_id bigint REFERENCES users ON DELETE CASCADE,
post_id bigint REFERENCES posts ON DELETE CASCADE,
created_at timestamptz NOT NULL,
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX ON likes (post_id);

The primary key on the pair rejects a second like from the same user and finds a user’s likes. The second indexIndexExtra data, built from the records, that finds a record without a full scan. An index speeds up reads, but it takes space and every write must update it too. So a database indexes only what its queries need.Read more: Storage engines: Finding a record fast finds a post’s likes, so the relation is read in both directions. Follows have the same shape: the row (follower_id, followee_id) means one user follows another, and following back is a separate row. Frequent queries on the list get indexes this way, such as (post_id, created_at) for comments in order (more on composite indexes). A query says what it needs, and the database picks the indexes and how to combine the tables. A new query can use the same tables right away, and an index makes it fast when it picks a few rows; a query that combines or counts many rows may still need a precomputed result.

Tables linked by keys, with a join table for likes Three tables. users holds each user once: 42 ana, 77 bo. posts stores author_id, a foreign key to users: one user has many posts. likes is a table of its own for a many-to-many relation: each row pairs a user_id with a post_id, such as user 42 and post 81. It has a primary key on (user_id, post_id) and an index on post_id. users ID | NAME 42 | ana 77 | bo AUTHOR_ID one user, many posts posts ID | AUTHOR_ID 81 | 42 90 | 77 likes many-to-many USER_ID | POST_ID 42 | 81 77 | 81 42 | 90 USER_ID POST_ID PRIMARY KEY (user_id, post_id) INDEX (post_id)

Normalization keeps each fact in one place, and everything else refers to it by key. A user’s name lives only in users; posts and comments store author_id. A rename is one update, and no post keeps the old name. Normalization is about correctness: when a fact has several copies, an update that misses one leaves them disagreeing. The price is paid on reads. To show a post with its author’s name, a join matches the post’s author_id to a row in users.

Constraints make the database enforce the rules, so a bug in one service cannot break them. The primary key on likes blocks the second like, a foreign key blocks a like for a post that does not exist, and ON DELETE CASCADE removes an account’s likes with it. A change that touches several rows at once, such as deleting a post with its comments, relies on transactionsTransactionA group of reads and writes that the database applies as one unit. Either all of its changes happen or none do, and concurrent transactions do not see each other's half-done work.Read more: Transactions: Changing several rows at once.

PostgreSQL and MySQL are the usual choices here.

Keep the core entities in a relational store, normalized. Give each many-to-many relation its own table with a unique key on the pair and an index for each direction. Let constraints enforce the rules that must always hold.

The home feed stays expensive even with these indexes: each time it opens, it merges the latest posts of hundreds of authors.

Users open the home feed far more often than they post. A normalized feed repeats the same work on every open: find whom the user follows, take their latest posts, merge and sort them, then join each post to its author’s name and count its likes. A read that runs this often can read a copy instead. Denormalization stores data a second time, in the shape a read needs. A copy takes one of three forms:

  • A copied attribute: the author’s name stored in each post row, so a post shows without a join to users.
  • An aggregate: a like_count column on the post, so a view does not count rows in likes.
  • A precomputed result: each user’s feed stored as a ready list, extended when a followed author posts. It is the result of the feed query, computed ahead of time.

The read becomes one indexed range of a single user’s rows:

SELECT post_id, author_id
FROM feed_entries
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50;

Every copy moves work to writes. A new post becomes one feed row per follower, a like updates the counter as well as likes, and a rename must reach every post that holds the old name. A copy also takes space, and it can disagree with the original. So name what updates each copy. Updating it in the same transaction as the original keeps it exact but makes the write slower, and a busy counter becomes a point where writes wait for each other. Updating it later in the background is cheaper, but the copy lags behind. Putting posts into followers’ feeds has its own trade-offs, such as authors with millions of followers.

A normalized feed works on every read; a precomputed feed works on every write Both panels show the same write and read. Normalized feed: a new post by Bo is one row in posts; when Ana opens her feed, the store finds the latest posts of the 200 authors she follows, then merges, sorts, and joins them. Precomputed feed: a new post by Bo is written to posts and as one row in the feed of each of his 200 followers; when Ana opens her feed, it is one range read of her own rows. The work moves from reads to writes. Normalized feed work on every read WRITE New post by Bo posts: 1 row 1 ROW READ Ana opens the feed posts of 200 authors merge, sort, join 200 AUTHORS PER READ Precomputed feed work on every write WRITE New post by Bo posts + 200 feeds 200 FEED ROWS READ Ana opens the feed Ana's feed read 50 rows 1 RANGE READ

Copy what rarely changes, and keep IDs for what changes often. In 2012, each entry of Twitter’s home timeline held only the post ID, the author’s ID, and a few flags, and the service looked up the rest on each read (talk). Text, names, and counts change often, and a copy of them in every timeline would turn each change into millions of updates. Copy a name only when the product states how stale it may be, or when it must show the value as it was at that moment. Such a copy keeps the value from that moment and does not follow later renames.

Read normalized data with good indexes first. Denormalize one hot read that you measured, and state how fresh the copy must be and what updates it.

A post is more than one row now: a caption, several photos in a set order, tags, a place, sometimes a poll. Each photo entry holds a reference to the file, its size, and its position; the file itself is stored elsewhere. The post page shows all of it at once. Normalized, that is a post row plus rows in post_photos, post_tags, and poll_options: several queries, or joins that repeat the post row next to each photo.

The document model keeps such a tree in one record, usually JSONJavaScript Object NotationA text format for nested data: objects with named fields, lists, strings, numbers, and booleans. Document stores keep records as JSON or a binary form of it, and relational databases have JSON columns.Read more: Wikipedia: JSON:

{
"id": 81,
"author_id": 42,
"caption": "Sunset at the pier",
"photos": [
{ "file": "p81-1.jpg", "width": 1080, "height": 1350 },
{ "file": "p81-2.jpg", "width": 1080, "height": 1080 }
],
"tags": ["sunset", "sea"],
"place": { "name": "Santa Monica Pier", "lat": 34.009, "lon": -118.497 }
}

One request returns the whole tree, because the store keeps the record together. This is locality: data used together is stored together, so reading it takes fewer lookups.

The design question is what goes inside the record. Embed a part when the record owns it, its size stays bounded, and it is read and changed with the record, such as the photo list or the poll options. Reference a part, that is, store it separately and keep its ID, when it grows without bound, is shared by many records, or is read on its own. A popular post gets thousands of comments, which are paged separately, and one author has many posts, so both stay references, like author_id above.

The same post as several tables or as one document Both panels open post 81 with the same data: the caption and place, two photos, and two tags. Normalized, they live in three tables, read with several queries or joins. As a document, they live in one record, read with one request. In both models, the author, the comments, and the photo files are stored apart and found by ID. Normalized three tables Open post 81 Document one record Open post 81 posts 1 row post_photos 2 rows post_tags 2 rows SEVERAL QUERIES OR JOINS caption, place photos: 2 entries tags: 2 1 REQUEST STORED APART IN BOTH, FOUND BY ID author 42 comments: thousands photo files

The model fits trees well and links between records poorly. Likes and follows connect many records to many, and many document stores join weakly or not at all, so the app runs several queries and joins the results in its code. A large document costs more to read and to change, by how much depends on the engine, and products cap its size: 16 MiB per document in MongoDB, 400 KB per item in DynamoDB. In MongoDB a write to one document is atomic, so a post and its photo list change together; an atomicAtomicityThe rule that a set of changes is applied fully or not at all. A failure in the middle never leaves part of the changes behind. A transaction gives it to several rows, and most stores give it to a single record.Read more: Transactions: Changing several rows at once change across documents needs a transaction.

A relational store can keep the same tree in one row: a JSON column holds the photo list next to the typed columns, and PostgreSQL and MySQL both support it.

Keep the current store and embed the bounded parts that a record owns, for example in a JSON column. Choose a document store when most reads and writes take whole records and links between records are rare.

The app adds post types: a video has a duration and a cover frame, a poll has options and an end date, a shared link has a URL and a preview. Each type has its own fields, and new types keep coming. There are four ways to store them:

  • a table per type: strict, but each new type is a new table, and a feed of mixed posts has to combine all of them;
  • one wide table with a column for every field: each row leaves most columns empty, and each new field changes the table;
  • a JSON column for the fields that vary;
  • a document store, where each record carries its own fields.

The choice turns on where the structure is checked. A schema is the structure that records follow. With schema-on-write, the database checks every write against it, as a relational table does. With schema-on-read, the store does not check records on write, and the code that reads them decides what the fields mean. “Schemaless” is a misleading name: the reading code still expects a structure, and it must handle every shape that was ever written.

Where the structure is checked: on write or on read The same two writes go to both stores: a video post by author 42, and a poll post without an author_id. With schema-on-write, the database checks each write, stores the video post, and rejects the poll post back to the writer, so readers trust the shape. With schema-on-read, the store keeps both records as they came, and the reading code must handle every shape it finds. Schema-on-write the database checks {"author_id": 42, "type": "video"} {"type": "poll"} Schema-on-read the reading code checks {"author_id": 42, "type": "video"} {"type": "poll"} Database checks every write REJECTED {"author_id": 42, "type": "video"} Reader trusts the shape Store keeps what comes {"author_id": 42, "type": "video"} {"type": "poll"} Reader handles both

Changing the structure costs differently. With schema-on-write, a migration changes the table. Adding a column that may be empty is usually cheap, while changing a column’s type can rewrite or lockLockA mark that reserves a row or a range of rows for one transaction and makes conflicting transactions wait. A write takes an exclusive lock on its row until the transaction ends, and readers can share a shared lock. Locks prevent conflicting writes but cause waits and deadlocks.Read more: Transactions: Seeing unfinished changes the whole table; the details depend on the operation and the database. With schema-on-read, new records take the new shape at once, and old records keep the old one until something rewrites them. Either way, the change has to be timed with the app’s releases.

Most designs mix the two. Typed columns hold what the database must filter, join, or check, and a JSON column holds the rest:

CREATE TABLE posts (
id bigint PRIMARY KEY,
author_id bigint NOT NULL REFERENCES users,
type text NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb NOT NULL
);

The database makes sure that every post has an existing author and a type, and a CHECK constraint can also limit type to known values. Each type keeps its own fields in payload. PostgreSQL can index fields inside the JSON when a query filters on them. The two models have grown toward each other: relational stores added JSON types and indexes, and MongoDB added joins and schema validation.

Treating a schemaless store as a reason to skip designing the records. The reading code still depends on their structure, and every old shape stays in the data until it is rewritten.

Use typed columns plus a JSON column in the relational store. Choose a document store when most records are independent trees with varied fields.

Each post carries photos and videos: a photo takes a few megabytes, a video can take hundreds. A file is written once and then read many times, by viewers all over the world. Kept in database rows, files would make tables and backupsBackupA copy of data as it was at an earlier moment. Replicas copy a bad write within seconds, so recovering data from before it needs an older copy. A backup is proven only by a test restore.Read more: Replication: Undoing a bad write huge, and every view would pull its bytes through the database server.

Object storage is built for such files. It keeps objects, each a file plus a little metadata, under keys in one flat namespace, and the app writes and reads whole objects over HTTPHypertext Transfer ProtocolThe request-response protocol of the web. A client sends a request with a method (GET, POST, …), a path, and headers. The server sends back a status code, headers, and a body. Most public APIs run on HTTP.Read more: Networking: Agreeing on the format. A “folder” is only a shared prefix of keys. Space is cheap, the store grows without limit, and a CDNContent Delivery NetworkA network of servers around the world that cache content close to users. A CDN cuts latency for users and load on your servers. It suits static files and responses that many users read.Read more: Networking: Users far away in front of it serves the files from servers near the viewers (more on serving users far away). The price is a second place to manage: an upload and its metadata row, or a delete and its file, now happen in two stores, and many requests and data transfers are billed, which a busy media feed must budget for. Amazon S3 is the best-known one, and other clouds offer the same model.

The database keeps the metadata and the object key, such as the file reference in the post record. Treat a key as immutable: an edited photo gets a new key, so cachesCacheA copy of data or results that is faster to read than the original and can be rebuilt from it. It cuts latency and load on the source while it answers most reads. Losing it makes a system slower but not wrong.Read more: Caching: When a cache pays off and old references never mix two versions. The store allows overwrites, so this rule lives in the app. The client uploads a file straight to the store with a pre-signed URL, a link that allows uploads to one key until it expires, and the post stays pending until the upload completes. The link works more than once while it lasts, so keep its lifetime short.

Uploading a photo straight to object storage Time runs top to bottom. The client asks the app to create a post; the app saves it in the database as pending and returns a pre-signed URL. The client uploads the file straight to object storage, so the bytes do not pass through the app. Object storage tells the app that the upload is complete, and the app marks the post ready. Client App Database Object storage CREATE POST SAVE AS PENDING PRE-SIGNED URL UPLOAD THE FILE UPLOAD COMPLETE MARK READY

DurabilityDurabilityThe promise that a confirmed write survives a crash or a power loss. A write is durable only after a flush, such as fsync, puts it on disk. The store chooses whether to confirm a write before or after that flush.Read more: Storage engines: Surviving a crash and availability are different promises. S3 is designed for 11 nines of durability, so a stored object is almost never lost. It may still be briefly unreachable: S3 Standard is designed for 99.99% availability, and its SLAService-level agreementA promise from a provider about a service level, such as monthly availability. Breaking it usually earns the customer a credit, not the lost time back.Read more: Wikipedia: Service-level agreement promises 99.9%.

Keep large media in object storage and their metadata in the database. Keep a small binary value in a database row only for a concrete benefit, such as writing it in the same transaction as the row.

Some data is only ever read by its full key. A session maps a token to a user and is checked on every request. A feature flag is read by its name. A map from each media ID to the account that owns it is checked on every media request. Each read has the whole key, and nothing asks what is inside the values:

session:7f3a9c → {"user_id": 42, "expires": "2026-10-13T10:00Z"}
flag:new-editor → {"enabled": true, "percent": 10}
media:981 → {"owner_id": 42}

The key-value model offers exactly this: get, put, and delete by the full key. The value is opaque to the store, so it cannot find records by a field inside the value. In return, every operation goes to one place that the key alone determines, which makes the data easy to spread across machines. Many key-value stores can also delete a key after its TTLTime To LiveHow long a piece of data stays valid before it must be refreshed or dropped. Caches and DNS answers use a TTL to limit how long stored data can be reused. A short TTL means fresher data but more refreshes. A long TTL means fewer refreshes, but changes spread more slowly.Read more: Caching: Putting a cache in front of the database. For sessions, still check the expiry on every read: a store may remove expired keys some time later. Such a store may keep its data in RAMRandom-access memoryThe main memory of a server: fast, but lost on restart and much more expensive than disk. Data and indexes that fit in RAM are read without disk access.Read more: Wikipedia: Random-access memory or on disk (more on keeping data in memory), and it often serves as a cache in front of another store (more on putting a cache in front of the database). Redis and DynamoDB are common choices.

The model stops at the key. “All sessions of this user” or “flags changed today” needs a field or a range of keys, which a plain key-value store does not have. Some stores add them: DynamoDB has a second key that orders items sharing the first one, chosen when the table is created, and secondary indexes.

The same session as a table row and as a key-value pair Left: the session is a row in a table of the main store, with columns token, user_id, and expires. A query by token uses its index, and a query by user_id can get a second index. Right: in a plain key-value store the session is a key, session:7f3a9c, and a value that holds the same user_id and expiry, opaque to the store. A get by the full key goes to one place; a question about the values, such as all sessions of user 42, has no path. Table in the main store the store sees every column QUERIES Plain key-value store the store sees only the key QUERIES TOKEN 7f3a9c USER_ID 42 EXPIRES 10:00 KEY session:7f3a9c VALUE user_id: 42 expires: 10:00 WHERE token = '7f3a9c' its index finds it WHERE user_id = 42 a second index GET session:7f3a9c one place sessions of user 42? no path

Start with an indexed lookup by key in the existing store. Add a key-value store when measured load or latency needs it and every read has the full key. When queries need a field or a range, use a store with ordered keys or indexes.

The network adds direct messages. Users send billions of them, a message is rarely changed after it is sent, and the history is kept for years. Before choosing where messages live, list the queries with how often they run, how fast they must be, and what must stay correct:

  1. Send a message to a chat: very frequent; a sent message must not be lost, and a chat keeps one order.
  2. Read the latest 50 messages of a chat, then older pages: the most frequent read, and it must be fast.
  3. List a user’s chats, the most recent first, with unread counts: on every app open.
  4. Delete all messages of a deleted account: rare, but required.

Every query, the rare ones too, needs a path to its data. A relational store adds paths later: a new query usually needs only a new index, and joins combine tables at read time.

Stores without joins or flexible indexes commit the layout to the queries instead. Each query gets a table shaped for it, often named after it: messages_by_chat for the second query, chats_by_user for the third. A write goes to every table that holds a copy, and a new query often means a new table and a backfill, a job that copies the existing data into it. messages_by_chat is keyed by chat, so the fourth query, finding one user’s messages, needs another table or a scan of everything. Table per query is the common strategy in such stores, though newer Cassandra versions have indexes on other columns, and a query by such an index may have to ask every machine.

A table per query, and a backfill for a new one Top: Ana sends hi to chat 7. The send updates two tables: it adds the message to messages_by_chat, keyed by chat, which serves the latest messages of a chat, and updates the chat list rows in chats_by_user, keyed by user. Bottom: the query that deletes Ana's messages has no message table keyed by user. Either scan all messages on every delete, or create messages_by_user, keyed by sender_id, and backfill it: copy every existing message into it. From then on, each send writes three tables. ONE SEND UPDATES TWO TABLES Ana sends "hi" to chat 7 messages_by_chat key: chat_id chat 7 | m1 | ana: hi chat 7 | m0 | bo: hey chats_by_user key: user_id ana | chat 7 bo | chat 7 2. Latest 50 of a chat 3. My chats 4. DELETE ANA'S MESSAGES: NO MESSAGE TABLE IS KEYED BY USER messages_by_chat every chat, every existing row BACKFILL: COPY ALL ROWS messages_by_user new table, key: sender_id or scan all messages on every delete from then on, each send writes 3 tables

Name the queries before the store. List them with their rates and correctness needs, then pick a model that serves all of them, and say which query each table or index is for.

Design from the data and the queries in a relational store, and add indexes as queries appear. When the chosen store requires a layout per query, list every query, the rare ones included, before creating tables.

The messages need a layout that keeps one chat’s messages together and in order.

The messages_by_chat table must return a chat’s newest messages fast, while the network writes millions of messages across many machines. The wide-column model stores rows in partitions. The partition key decides which partition holds a row, and so which machines store it. A clustering key sorts the rows inside the partition. A query names a partition and reads a range of its sorted rows:

CREATE TABLE messages_by_chat (
chat_id bigint,
bucket int,
message_id bigint,
sender_id bigint,
body text,
PRIMARY KEY ((chat_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

The partition key is what the query filters on: here the chat together with a time bucket. The clustering key is message_id: it must be unique, or a write with the same key silently updates the existing row, and it must sort by time. A created_at timestamp sorts by time but is not unique, so message IDs are generated to grow over time. Reading the latest 50 messages then starts with one range read in the newest partition. Writes are cheap, because these stores sort writes in memory and flush them to disk in batches (more on LSM-trees).

The layout serves only the queries it was built for. There are no joins, the sort order is fixed when the table is created, and a query without the full partition key may have to ask every machine.

A partition must also stay bounded. One chat that lives for years would grow into a partition too large to read, compact, or move between machines. So the partition key gets a time bucket: (chat_id, bucket) starts a new partition every few days or weeks. Size the bucket from the worst case: the busiest chat’s message rate times message size times the bucket’s length. When the newest bucket holds fewer than 50 messages, the read continues in older buckets until the page is full. Discord stored messages this way in 2017, with buckets of about 10 days, to keep partitions under 100 MB after Cassandra logged warnings about larger ones (Discord blog). A very busy chat can still become a hot keyHot keyOne key value that gets more traffic than a whole shard can serve. Adding or moving shards does not help, since the key lives on one shard. Cache it for reads, or salt it for writes.Read more: Sharding: One key with too much traffic: all its writes go to the current bucket, on the same few machines.

The key of messages_by_chat, and reading the latest messages of a chat Top: the primary key has two parts. The partition key, chat_id and bucket, picks the partition and the machines that store it. The clustering key, message_id, sorts the rows inside the partition, newest first. Bottom: chat 7 has one partition per time bucket of about ten days. Reading its latest 50 messages starts with one range read in the newest bucket, which holds only two messages so far, and continues in the previous bucket until the page is full. The oldest bucket is not read. PRIMARY KEY ((chat_id, bucket), message_id) PARTITION KEY chat_id, bucket picks the partition and its machines CLUSTERING KEY message_id sorts rows inside it, newest first READ: LATEST 50 MESSAGES OF CHAT 7 chat 7, bucket 10 Oct 1 to 10 1120 1119 1118 … chat 7, bucket 11 Oct 11 to 20 1180 1179 1178 … chat 7, bucket 12 Oct 21 to now 1209 1208 NOT READ 2. CONTINUE HERE 1. START IN THE NEWEST

DynamoDB uses the same idea of a partition key and a sort key, with its own limits.

Serve ordered reads from the relational store first: an index on (chat_id, message_id) returns the latest messages too. Choose a wide-column store when measured write load needs many machines, the queries are known in advance, and every partition stays bounded. A large amount of data alone is not a reason.

“People you may know” suggests the users followed by the people you follow, ranked by how many of your follows also follow them. That is two hops through the follows table: a bounded join of the table with itself, or a list computed in the background and stored. It stays in the relational store.

The trust and safety team asks a different kind of question. Fake accounts come in rings: they share phones, IP addressesInternet Protocol addressThe number that identifies a device on a network. IP, the Internet Protocol, delivers packets to these addresses with no guarantees; TCP and UDP run on top of it. DNS maps names to IP addresses.Read more: Networking: Finding the server, and reposted photos, and the links between them can run through any number of steps. An investigator starts from one account and asks how it connects to accounts already banned, and asks it again with every new clue.

The graph model fits such questions. Data is vertices, such as accounts, devices, and photos, and edges between them, both with properties. A query follows stored edges from vertex to vertex, so paths of unknown length and patterns of links are easy to express:

MATCH path = (a:Account {id: $id})-[*1..6]-(b:Account {banned: true})
RETURN path
LIMIT 10;

This finds paths of up to six links of any type from one account to a banned one. In SQLStructured Query LanguageThe standard language for querying and changing data in relational databases. A query says what data it needs, and the database chooses how to get it, such as which indexes to use.Read more: Wikipedia: SQL each hop is a join, and an unknown number of hops needs a recursive query that is hard to write and to tune. Edges have a direction: a follow is an edge from the follower to the followed user. Any model struggles with a vertex that has a huge number of edges, such as a celebrity with 100 million followers: a query that walks all its edges is slow wherever they are stored. A graph database is one more store to run and fill: when the relational store owns the accounts and posts, the graph is a copy that must follow it. Neo4j is the best-known graph database.

A fixed two-hop query versus a path of variable length Top: people you may know. You follow ana, and ana follows cy, so cy is a suggestion: a fixed query of two hops through the follows table. Bottom: an investigation. Account 1 shares a phone with account 2, which shares an IP address with account 3, a banned account. The path length is not known in advance; the example query looks for paths of up to six links. People you may know two hops you ana cy HOP 1 HOP 2 cy: a suggestion Investigating a ring variable path, up to 6 links here account 1 phone account 2 IP address account 3 banned LINK 1 LINK 2 LINK 3 LINK 4

The social graph itself does not need a graph database. In 2013, Facebook described serving its graph of users, posts, and links from MySQL, split across many servers, behind a cache that understands objects and links (TAO paper).

Keep links in an edges table, or store precomputed results, in the relational store. Choose a graph database when the product needs interactive queries over paths and patterns of unknown depth and measured performance shows the relational store cannot serve them.

The network measures itself. Each service reports its request rate, latency, and errors every 10 seconds, and on-call engineers watch dashboards and get alerts. Creators also see how many views their posts get per hour. Both are measurements over time, but at very different volumes.

The platform’s own metrics live in a time-series store, usually as part of the monitoring stack. Its workload has a typical shape. Points arrive mostly in time order and are appended. Queries ask the same questions over recent windows: the rate, average, or 99th percentilePercentileThe value below which a given share of measurements falls. The 99th percentile of latency is the time that 99% of requests beat. It shows slow requests that an average hides.Read more: Wikipedia: Percentile over the last five minutes. Old data goes by retention, the period the store keeps data: many stores keep data in time blocks and drop a whole expired block at once, which is far cheaper than deleting rows one by one. Rollups store older data at a lower resolution, such as one point per hour, when long history must stay affordable; some stores compute them, others leave it to you. A query names a metric, filters it by labels, and applies a window:

rate(http_requests_total{service="feed", status="500"}[5m])
Time blocks: full detail for recent data, rollups for older data, expired blocks dropped Time runs left to right. The three newest blocks keep a point every 10 seconds, and new points are appended to the last one. Two older blocks keep rollups, one point per hour. The oldest block is past retention, and the store drops it as one block instead of deleting points one by one. past retention rollups: 1 point per hour full detail: a point every 10 s DROP AS ONE BLOCK APPEND OLDER NOW

A series is a metric name plus one combination of label values. In Prometheus, every combination is a separate series, so a label with unbounded values, such as a user ID or a post ID, multiplies the series and the memory they need. Other stores handle many series differently, so check the product before you add such a label. Prometheus, InfluxDB, and TimescaleDB are common choices. How services produce these metrics is part of observability.

While creators’ views per hour stay a modest stream, they fit in the main store: one row per post per hour, in a table with the key (post_id, hour).

Keep platform metrics in the monitoring stack. Keep modest app measurements in a table indexed by time. Move them to a time-series store when the table no longer meets the write rate, the window queries, or the retention.

Creators and the product team ask new questions every week: views of this post by country and day, how many new users come back after a week, which post types grow. Each view, like, and signup is recorded as an event, one row each, with the post, the user, the country, and the time. The questions group and join these events across months of history:

SELECT country, CAST(viewed_at AS date) AS day, count(*) AS views
FROM view_events
WHERE post_id = ?
AND viewed_at >= now() - INTERVAL '30 days'
GROUP BY country, day
ORDER BY day, views DESC;

This is analytics: large scans that read a few columns of many rows. On the app’s database they compete with its own work, so the events go to a separate analytics store with column storageColumn storageKeeping the values of each column together instead of each record together. A query reads only the columns it needs, and columns often compress well. New rows are collected in memory and written to the columns in batches, so work on single records is less efficient.Read more: Storage engines: Scanning for analytics (more on scanning for analytics). ClickHouse, Apache Druid, and Apache Pinot are common open-source choices, and cloud warehouses such as BigQuery, Snowflake, and Redshift serve the same workload as managed services.

The difference from time-series lies in the typical workload, and both may use column storage. A time-series store mostly answers repeated window questions over regular measurements, such as error rates. An analytics store mostly answers new questions over business events with many fields, such as the post, the user, and the country, and keeps the raw events, so tomorrow’s question can still be asked about last year.

A number that the app shows on every page view, such as a creator’s daily views, is better computed ahead as a rollup than scanned each time. A query on demand against the analytics store is fine when it is bounded, such as one post over 30 days, and measured latency and load allow it. Events reach the analytics store in batches or as a stream, so reports lag behind the app.

Raw events answer new questions; a rollup answers a fixed one Left: raw view events in the analytics store, one row per view with the post, the country, and the time. A new question from the product team, views by country, scans all of these rows. Right: a rollup precomputed from them, one row per post and day with the number of views. The app shows daily views of post 81 by reading one row. Raw events POST | COUNTRY | TIME Rollup POST | DAY | VIEWS 81 | BR | 10:01:07 90 | US | 10:01:09 81 | DE | 10:01:12 … 90 | Oct 12 | 310 81 | Oct 12 | 2,140 PRECOMPUTE Product team: views by country SCAN ALL ROWS App: daily views of post 81 READ ONE ROW

Use counters or rollups for numbers the app shows all the time. Add an analytics store when exploratory queries scan enough data to slow the app.

Users search posts by words in captions and by hashtags, and people by name, often with a typo. A B-treeB-treeA storage structure that keeps sorted keys in a tree of fixed-size pages and changes them in place. A lookup follows one page per level, and the upper levels stay in memory. Leaves in key order serve ranges. It is the default index of most relational databases.Read more: Storage engines: Updating in place index sorts whole values, so it cannot narrow a search for a word inside a caption: without another kind of index, LIKE '%sunset%' reads every row.

An inverted index maps each word to the list of records that contain it. A query that needs both of two words takes both lists and keeps the records found in both. Before indexing, text analysis splits the text into words and normalizes them, for example to lowercase; with stemming on, it also reduces each word to its stem by the rules of its language, so “running” also finds “run”. The search then ranks the matches by relevance, such as how often and where the words appear, and fuzzy matching finds words that differ by a typo or two.

An inverted index maps each word to the records that contain it Three captions: 1 Beach sunset, 2 Pier sunset, 3 Running beach. Analysis splits them into words, lowercases them, and stems running to run. The index maps beach to posts 1 and 3, pier to 2, run to 3, and sunset to 1 and 2. The query beach AND sunset takes the lists 1, 3 and 1, 2 and keeps post 1, found in both. Captions 1 Beach sunset 2 Pier sunset 3 Running beach ANALYZE split, lowercase, stem: running → run Inverted index beach → 1, 3 pier → 2 run → 3 sunset → 1, 2 beach AND sunset {1, 3} ∩ {1, 2} post 1

The main store may already do this. PostgreSQL has full-text search with an inverted index and ranking, and trigram similarity for names with typos:

SELECT id
FROM posts, websearch_to_tsquery('english', 'beach sunset') AS q
WHERE search_vector @@ q
ORDER BY ts_rank(search_vector, q) DESC
LIMIT 20;

Here search_vector is a column computed from the caption and hashtags, with an inverted index on it.

A search engine goes further: analysis tuned per language, finely controlled relevance, counts by category next to the results (facets), and its own machines that scale apart from the main store. Elasticsearch and OpenSearch are the common choices. It holds a copy of the data, built from the stores that own it and updated in the background. A change appears in search after the copy receives it and the engine refreshes its index; in a cluster you run, Elasticsearch refreshes every second by default for indexes searched recently, and a setting changes it. Here the relational store stays the owner: the engine does not enforce the rules across records that the app needs, such as foreign keys.

Start with full-text search in the main store. Add a search engine when the product needs behavior the store lacks, such as per-language analysis, tuned relevance, or facets, or when search load needs its own machines.

“Similar posts” should find posts about the same thing, and a search for “beach sunset” should find a post captioned “evening at the sea”, which shares no words with it.

An embedding model turns a text or an image into a vector: a list of hundreds or thousands of numbers. Texts with similar meaning get vectors that lie close to each other. A query is turned into a vector by the same model, and the search returns the nearest vectors. Vectors from different models cannot be compared, so changing the model means computing the vectors of all records again.

Similar meanings lie close together as vectors Posts drawn as points by their vectors. The query beach sunset becomes a vector by the same model. The nearest vectors are evening at the sea and sunrise on the shore, which share no words with the query. Pasta recipe, my cat, city at night, and football match lie far away. VECTORS evening at the sea sunrise on the shore pasta recipe my cat city at night football match beach sunset

Exact search compares the query with every vector. It is fine for small sets, or when a filter first narrows the candidates to a small set. Approximate indexes, such as HNSWHierarchical navigable small worldAn approximate vector index built as a layered graph of nearby vectors. It is fast with good recall, but takes memory and time to build.Read more: PostgreSQL: Searching by meaning and IVFInverted file indexAn approximate vector index that groups vectors into clusters and searches only the nearest clusters. It builds faster and uses less memory than HNSW, with lower speed or recall.Read more: PostgreSQL: Searching by meaning, visit only part of the vectors, so they are much faster, but they can miss some true neighbors. Recall is the share of true nearest neighbors that a search returns, and an index trades it for speed and memory. Filters need care: in pgvector, a filter is applied after the approximate index scan, so a selective filter can leave too few results; iterative scans or another filtering strategy help, within their limits.

pgvector keeps the vectors in PostgreSQL, next to the rest of the post:

SELECT id
FROM posts
WHERE NOT hidden
ORDER BY embedding <=> ?
LIMIT 20;

The <=> operator is the cosine distance between two vectors. The post’s other fields stay in normal columns with normal indexes, linked to the vector by the post ID. The nearest vectors are candidates: the app still removes deleted posts and blocked authors, and ranks the rest. Search engines also store vectors, and dedicated vector databases, such as Milvus, Qdrant, and Weaviate, are built for vector search first, often with keyword search too.

Keep vectors in a store you already run: pgvector in PostgreSQL, or the search engine. Choose a dedicated vector database when measured recall with filters, latency, memory, or the time to rebuild the index require it.

The network now keeps data in several stores, and many facts exist in more than one of them. A post’s caption is in the relational store, in the search index, and, as a vector, in the similarity index. When the copies disagree, one of them must be right.

Each fact needs one owner, the system of record: the store where the fact is written first and which wins any disagreement. Here the relational store owns users, posts, likes, and follows; the message store owns messages; object storage owns the media files; and raw view events are owned by the store where they are first recorded, such as an event log that feeds analytics. Everything else is derived data: the feeds, the counters, the search index, the vectors, and the rollups are built from the owners. A derived copy can be rebuilt only while its inputs are kept. Rollups need the raw events, and vectors need the posts and the same model.

Each fact has one owner; everything else is derived from the owners Owners on the left: the relational store owns users, posts, likes, and follows; the message store owns messages; object storage owns media files; a raw-event store, such as an event log, owns raw view events. Derived copies on the right are built from them: feeds, counters, the search index, and the vectors from the relational store, and rollups from the raw events. A derived copy can be rebuilt while its inputs are kept. OWNERS DERIVED COPIES Relational store users, posts, likes, follows Message store messages Object storage media files Raw-event store view events, e.g. an event log Feeds Counters Search index Vectors Rollups

Each derived copy needs two promises. The first is how far behind the owner it may be: seconds for search, minutes or hours for analytics reports. The second is how deletes reach it: a deleted post must leave the feeds, the search index, and the similarity index, and its files must go too.

The simplest way to update copies is to have the app write to each store in turn: first the main store, then the search engine. These dual writes break in two ways. One write can fail while the other succeeds. And two concurrent updates of the same post can reach the two stores in different orders, so the stores keep different versions, with no error anywhere.

Instead, derived copies follow the owner’s committedCommitThe end of a transaction that makes all of its changes permanent at once. Other transactions see the changes only after the commit. A committed change is final: undoing it takes a new transaction that does the opposite.Read more: Transactions: Changing several rows at once changes. Change data capture (CDC) reads the changes from the owner’s log after they commit. An outbox writes each change as an event into a table in the same transaction as the change itself, and a separate process delivers the events. Either way, the changes of one record must be applied in the order they were made, and a retry may deliver a change twice, so applying it twice must have no extra effect.

Dual writes versus copies that follow the owner Both panels hold the same app, main store, and search engine. Left: dual writes. The app writes caption version 2 to the main store, then to the search engine; the second write fails, so the search engine keeps version 1. Right: the app writes only to the main store, the owner, and CDC or an outbox delivers its committed changes to the search engine, which reaches version 2. Dual writes the app writes each store App 1. WRITE Follow the owner copies follow committed changes App 1. WRITE Main store caption v2 Search engine caption v1 Main store caption v2 Search engine caption v2 2. WRITE FAILED CDC or outbox COMMITTED CHANGE

Keeping separate models for writes and reads is also a pattern of its own, CQRSCommand query responsibility segregationUsing separate models for writing data and for reading it. Reads get models shaped for them, which follow the write model with some delay.Read more: Martin Fowler: CQRS. Event sourcingEvent sourcingStoring every change as an event and deriving the current state from the events. The full history is kept and views can be rebuilt. Deleting data and changing event formats become harder.Read more: Martin Fowler: Event Sourcing, where the log of events is the owner and every table is derived from it, is a separate choice.

Add a store only when its benefit pays for keeping it in sync. For each copy, name its owner, how far behind it may be, and how deletes reach it.

Most data starts in the relational store, and each move to another store needs a concrete reason in the workload or the product:

Data and its main query Model Default Switch when
Users, posts, likes, follows: reads with joins, rules that must hold Relational Normalized tables with indexes for frequent queries A measured hot read needs a copy: denormalize that read, keep the store
A post with its photos, tags, and poll: read whole Document A JSON column in the relational store Most reads and writes take whole records, and links between records are rare
Post types with different fields Typed columns and JSON, or document Typed columns plus a JSON column Most records are independent trees with varied fields
Photos and videos: written once, read many times Object storage Object storage behind a CDN, metadata in the database A small binary value gains something concrete from living in a row
Sessions, flags: read by the full key Key-value An indexed lookup in the existing store Measured load or latency needs it, and every read has the full key
Messages: the latest N of a chat, many writes Wide-column A relational table with an index on (chat_id, message_id) Write load needs many machines, queries are known, partitions stay bounded
Links between accounts: paths of unknown depth Graph An edges table or precomputed results Interactive path queries are required and the relational store measurably cannot serve them
Measurements over time: window queries Time-series The monitoring stack for platform metrics, a table indexed by time for app numbers The table no longer meets the write rate, the window queries, or the retention
Events for new questions: large scans Analytics, column storage Counters or rollups Exploratory queries scan enough data to slow the app
Text: search by words Inverted index Full-text search in the main store The product needs behavior the main store lacks, such as per-language analysis, tuned relevance, or facets, or search needs its own machines
Posts and images: search by meaning Vectors pgvector or the search engine Measured recall with filters, latency, memory, or rebuild time require a dedicated store

Each fact still has one owner. The feeds, counters, search index, vectors, and rollups are copies that follow their owners, with a stated delay, and every delete must reach them.

  • List the queries and the rules that must hold before you pick a store. Keep core entities in normalized relational tables: a join table per many-to-many relation, indexes for frequent queries, constraints for the rules.
  • Read normalized data with good indexes first. Denormalize one measured hot read, with a stated freshness and a named process that updates it; copy what rarely changes and keep IDs for what changes often.
  • Embed the bounded parts a record owns and reference what grows without bound, is shared, or is read alone. Use a JSON column first; choose a document store when most access takes whole records and links between records are rare.
  • Records always have a structure: schema-on-read only moves the check to the reading code. Use typed columns plus a JSON column; choose a document store when most records are independent trees with varied fields.
  • Keep large media in object storage behind a CDN, and their metadata and keys in the database. Durability is not availability.
  • Start with an indexed lookup by key in the existing store. Add a key-value store when measured load or latency needs it and every read has the full key.
  • Give every query a path, the rare ones too. A relational store adds paths with indexes; stores without joins or flexible indexes usually get a table per query and a backfill for each new one.
  • Serve ordered reads from the relational store first. Choose a wide-column store when measured write load needs many machines, queries are known, and every partition stays bounded, for example by a time bucket; data size alone is not a reason.
  • Keep links in an edges table or precompute results. Choose a graph database when interactive queries over paths of unknown depth are required and the relational store measurably cannot serve them.
  • Keep platform metrics in the monitoring stack and modest app measurements in a table indexed by time. In Prometheus, a label with unbounded values, such as a user ID, multiplies the series and their memory.
  • Use counters or rollups for numbers the app shows all the time. Add an analytics store when exploratory scans slow the app; time-series stores mostly serve repeated window questions, analytics stores mostly serve new questions over raw events.
  • Start with full-text search in the main store. Add a search engine when the product needs behavior the main store lacks, such as per-language analysis, tuned relevance, or facets, or search needs its own machines; it holds a copy that lags.
  • Keep vectors in a store you already run. Choose a dedicated vector database when measured recall with filters, latency, memory, or rebuild time require it.
  • Give each fact one owner. A copy in the same store can change in the same transaction as the original; a copy in another store follows the owner’s committed changes through CDC or an outbox rather than dual writes. Each copy has a stated delay and receives deletes.

© 2026 Grig Alex · CC BY-SA 4.0