Data models are perhaps the most important part of developing software — they shape not only how the code is written, but how you think about the problem. This chapter is the deep foundation for every system design decision you'll ever make. It covers the full landscape: relational vs document, SQL vs NoSQL, declarative vs imperative queries, and graph models with their query languages.
Why this matters for interviews: Every FAANG system design question starts with picking the right data model. "Should I use SQL or NoSQL?" is the first question interviewers expect you to answer with reasoned tradeoffs. This lesson gives you the vocabulary and mental models to handle it.
Most applications are built by layering one data model on top of another. Each layer hides the complexity of the one below it:
| Layer | What it models | Examples |
|---|---|---|
| 4. Real world | People, organizations, goods, actions, money flows, sensors | Your domain objects |
| 3. General-purpose data model | How you store your domain objects | JSON documents, relational tables, graph nodes/edges |
| 2. Storage engine | How bytes are arranged on disk / in memory | B-trees, LSM-trees, columnar storage (Chapter 3) |
| 1. Hardware | Electrical currents, magnetic fields, light pulses | CPUs, disks, SSDs, RAM, network fabric |
Each layer's abstraction lets different people work together effectively — the database vendor writes the storage engine, you write the application, and neither needs to understand the other's internals. Every data model embodies assumptions about how it will be used. Some operations become easy, some become impossible. Some transformations feel natural, some feel awkward.
Proposed by Edgar Codd in 1970. Data is organized into relations (tables), each an unordered collection of tuples (rows). The relational model's revolutionary idea: hide the internal representation behind a clean interface. Before SQL, developers had to think deeply about internal storage details.
Relational databases dominated from the mid-1980s for ~25-30 years. Their roots were in business data processing: transaction processing (sales, banking, airline reservations) and batch processing (payroll, invoicing). But they generalized remarkably well to online publishing, social networking, ecommerce, and gaming.
The key relational insight: you only need to build a query optimizer once, and then all applications that use the database benefit from it. Without a query optimizer, you hand-code access paths per query — which is easier in the short run but loses in the long run.
NoSQL emerged in the 2010s (the term started as a Twitter hashtag for a 2009 meetup on open-source, nonrelational databases). Driving forces:
| Driver | Why |
|---|---|
| Greater scalability | Very large datasets or very high write throughput that relational DBs couldn't easily achieve |
| Open source preference | Widespread preference for free/open source over commercial DB products |
| Specialized query ops | Operations not well supported by the relational model |
| Schema flexibility | Frustration with restrictive schemas; desire for a more dynamic and expressive model |
The likely outcome: polyglot persistence — relational databases will continue alongside a broad variety of nonrelational datastores, each used where it fits best.
Most application code is object-oriented. Data in tables is rows and columns. The translation layer between them is called an impedance mismatch. ORM frameworks (ActiveRecord, Hibernate) reduce the boilerplate but can't completely hide the differences.
The canonical example: a LinkedIn résumé (Figure 2-1). One user has multiple positions, multiple education entries, multiple contact methods — a one-to-many tree structure.
| Approach | Description | Pros | Cons |
|---|---|---|---|
| Normalized tables (pre-SQL:1999) | Separate tables for positions, education, contact_info with foreign keys to users | Full query power, no duplication | Requires multiple queries or messy multi-way joins to fetch one profile |
| SQL:1999 structured types / XML / JSON | Multi-valued data stored in a single row; queryable inside the document | Better locality, standard query support | Vendor-specific support levels |
| Application-encoded JSON/XML in a text column | Store the whole blob, let the app interpret it | Maximum flexibility | Cannot query inside the column; no schema enforcement |
The JSON representation has better locality: all the profile info is in one place — one query suffices instead of multiple joins. The one-to-many tree structure is made explicit (Figure 2-2). This is the core argument for document databases.
In the résumé JSON, why are region_id and industry_id stored as IDs,
not plain text? Because of normalization:
Using an ID means the human-meaningful text is stored in one place, and everything else references it. No duplication → no inconsistency risk. This is normalization.
But normalization introduces many-to-one relationships (many people live in one region). These don't fit the document model well. Relational databases handle them naturally with joins. Document databases have weak join support — you either denormalize (and risk inconsistency) or emulate joins in application code (multiple queries, slower).
Real-world complexity creeps in: even if your app starts with a simple document structure, features like recommendations (many-to-many between users) and organization entities (many-to-many between users and companies) inevitably create interconnected data that documents handle poorly. Figure 2-4 shows how the dotted rectangles of self-contained documents get punctured by cross-references.
This exact debate happened before. In the 1970s, IBM's IMS used the hierarchical model — data as a tree of records nested within records, remarkably similar to today's JSON model. It had the same strengths (good for one-to-many) and weaknesses (many-to-many was difficult, no joins).
Two solutions emerged to fix the hierarchical model's limitations:
Document databases reverted to the hierarchical model for one aspect — storing nested records within their parent. But they handle many-to-one and many-to-many relationships the same way as relational databases: using unique identifiers resolved at read time via joins or follow-up queries. They did not follow the CODASYL path.
| Factor | Document DB wins when… | Relational DB wins when… |
|---|---|---|
| Data shape | Document-like tree structure, loaded entirely at once | Many-to-one and many-to-many relationships dominate |
| Schema | Schema-on-read: heterogeneous data, types vary per item | Schema-on-write: uniform structure, schema as documentation |
| Locality | Entire document needed on every access | Queries touch different subsets of data across tables |
| Joins | Few joins needed; can denormalize | Many joins; denormalization creates inconsistency risk |
| Write patterns | Entire document written at once; updates don't change encoded size | Fine-grained updates to individual columns/tables |
| Property | Schema-on-Read (Document DBs) | Schema-on-Write (Relational DBs) |
|---|---|---|
| Analogy | Dynamic (runtime) type checking | Static (compile-time) type checking |
| When schema is needed | When data is read — app code assumes structure | When data is written — DB enforces structure |
| Adding a field | Just start writing new documents with the field; handle old docs in app code | ALTER TABLE (usually fast; MySQL copies entire table) |
| Good for | Heterogeneous data (different object types, external systems you can't control) | Uniform data structures where consistency matters |
| "Schemaless" | Misleading — there is always an implicit schema | Explicit schema documented and enforced |
Be careful with the term "schemaless": the code that reads the data always assumes some structure. Schema-on-read just means the database doesn't enforce it — the application code is responsible for handling missing fields.
A document is usually stored as a single continuous string (JSON, XML, BSON). Fetching the entire document means one disk seek, one read — storage locality. This is a significant performance advantage when you need large parts of the document together.
However:
Google's Spanner offers similar locality in a relational model via interleaved tables. Oracle's multi-table index cluster tables and Bigtable's column-family concept serve similar purposes.
The two models are becoming more similar over time:
The future is hybrid: databases that can handle document-like data and also perform relational queries, letting applications use the combination that fits their needs.
| Property | Declarative (SQL, CSS, XPath) | Imperative (general-purpose code) |
|---|---|---|
| You specify | What you want, with conditions and transformations | How to do it — step by step, line by line |
| Query optimizer | Chooses access paths, indexes, join order automatically | You control everything — no optimization freedom |
| Parallel execution | Naturally suited — only the pattern of results is specified | Hard to parallelize — instructions are ordered |
| Performance improvements | DB vendor can improve optimizer without changing queries | You rewrite code to take advantage of new APIs |
| Conciseness | Very concise (e.g., 1 line SQL vs 15 lines of imperative code) | Verbose; more surface for bugs |
Kleppmann illustrates this with a brilliant non-database example: highlighting a selected navigation item on a webpage. In CSS (declarative):
li.selected > p { background-color: blue; }
In JavaScript/DOM (imperative), the equivalent is ~15 lines of loops, conditionals, and attribute-setting — and it's wrong because removing the class doesn't automatically remove the highlight. CSS automatically detects when the selector no longer applies and adjusts. This captures exactly why declarative languages win for databases: they limit what you can express, giving the system room to optimize and maintain invariants.
MapReduce is neither fully declarative nor fully imperative — it's a hybrid. The query logic
is expressed with snippets of code (map and reduce functions),
called by a processing framework.
The shark-counting example — three approaches:
| Language | Code | Style |
|---|---|---|
| SQL |
SELECT date_trunc('month', observation_timestamp)
AS observation_month,
SUM(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
|
Declarative |
| MongoDB MapReduce |
db.observations.mapReduce(
function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getMonth() + 1;
emit(year + "-" + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
},
{ query: { family: "Sharks" },
out: "monthlySharkReport" }
);
|
Hybrid (declarative filter + imperative functions) |
| MongoDB Aggregation Pipeline |
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" }
}}
]);
|
Declarative (JSON-based) |
The moral: NoSQL systems often end up reinventing SQL in disguise. MongoDB's Aggregation Pipeline is JSON-based SQL. The declarative pattern wins because query optimizers can improve it, it's easier to read, and it parallelizes naturally.
MapReduce's map and reduce functions must be
pure functions — no side effects, no additional DB queries. This allows the
framework to run them anywhere, in any order, and retry on failure. But writing two carefully
coordinated JS functions is harder than writing one SQL query, and the optimizer has less room
to improve performance.
When many-to-many relationships are very common, graphs become the most natural representation. A graph has vertices (nodes/entities) and edges (relationships/arcs).
| Domain | Vertices | Edges |
|---|---|---|
| Social | People | Friendship |
| Web | Pages | HTML links |
| Road/rail | Junctions | Roads/rail lines |
| Facebook's graph | People, locations, events, checkins, comments | Friends, checkin-at, attend-event, comment-on |
Graphs are powerful because they can store completely different types of objects in a single datastore with a consistent model. The example in Figure 2-5 shows Lucy and Alain — people, cities, states, countries, continents all in one graph with WITHIN, BORN_IN, and LIVES_IN edges.
Each vertex:
Each edge:
Implemented by: Neo4j, Titan, InfiniteGraph. The model can be represented as two relational tables (vertices + edges, Example 2-2):
CREATE TABLE vertices ( vertex_id integer PRIMARY KEY, properties json ); CREATE TABLE edges ( edge_id integer PRIMARY KEY, tail_vertex integer REFERENCES vertices (vertex_id), head_vertex integer REFERENCES vertices (vertex_id), label text, properties json ); CREATE INDEX edges_tails ON edges (tail_vertex); CREATE INDEX edges_heads ON edges (head_vertex);
Key aspects:
Graphs are great for evolvability: you can model different regional structures across countries (US has counties+states, France has départements+régions), city/country nesting, and varying data granularity within one unified model. Adding features (like food allergies) just means adding new vertices and edge types.
Cypher is a declarative query language for property graphs. Named after the character in The Matrix. Uses ASCII-art arrow notation to describe graph patterns.
Inserting data (Example 2-3):
CREATE
(NAmerica:Location {name:'North America', type:'continent'}),
(USA:Location {name:'United States', type:'country'}),
(Idaho:Location {name:'Idaho', type:'state'}),
(Lucy:Person {name:'Lucy'}),
(Idaho) -[:WITHIN]-> (USA) -[:WITHIN]-> (NAmerica),
(Lucy) -[:BORN_IN]-> (Idaho)
Query: find people who emigrated from US to Europe (Example 2-4):
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]->
(us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]->
(eu:Location {name:'Europe'})
RETURN person.name
The :WITHIN*0.. syntax means "follow WITHIN edges zero or more times" — like the
* in a regex. This handles the variable-depth hierarchy (a city is WITHIN a
state, which is WITHIN a country, which is WITHIN a continent).
The same query in SQL (Example 2-5) requires recursive common table expressions — 29 lines instead of 4:
WITH RECURSIVE
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE properties->>'name' = 'United States'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices
WHERE properties->>'name' = 'Europe'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within'
),
born_in_usa(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in'
),
lives_in_europe(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in'
)
SELECT vertices.properties->>'name' FROM vertices
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;
What this comparison teaches us: 4 lines (Cypher) vs 29 lines (SQL) for the same query shows that data models are optimized for different use cases. The relational model wasn't designed for variable-length graph traversals. This is why graph databases exist — choosing the right model for the job matters.
The triple-store model is mostly equivalent to property graphs, using different vocabulary. All information is stored as three-part statements: (subject, predicate, object).
Each triple is like one fact: (Jim, likes, bananas). Subject = vertex. Object =
either a primitive value (property) or another vertex (edge). Predicate = property key or edge
label.
@prefix : <urn:example:>. _:lucy a :Person. _:lucy :name "Lucy". _:lucy :bornIn _:idaho. _:idaho a :Location. _:idaho :name "Idaho". _:idaho :type "state". _:idaho :within _:usa.
Semicolons let you say multiple things about the same subject, making it more readable:
_:lucy a :Person; :name "Lucy"; :bornIn _:idaho. _:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa. _:usa a :Location; :name "United States"; :type "country"; :within _:namerica.
RDF was designed for internet-wide data exchange. Predicates are URIs (e.g.,
<http://my-company.com/namespace#within>), so different datasets can be
combined without naming conflicts.
The semantic web vision: websites publish machine-readable data, allowing automatic combination into a "web of data." Overhyped in the early 2000s, but the technology (RDF, SPARQL, triple-stores) remains useful for internal applications regardless.
SPARQL ("sparkle") predates Cypher and inspired Cypher's pattern matching. The same emigrants query is even more concise:
PREFIX : <urn:example:>
SELECT ?personName
WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}
The / operator is SPARQL's path syntax: :bornIn / :within* means
"follow a :bornIn edge, then zero or more :within edges" — equivalent to Cypher's
-[:BORN_IN]-> () -[:WITHIN*0..]->.
| Feature | Cypher | SPARQL | SQL (recursive CTE) |
|---|---|---|---|
| Variable-length path | -[:WITHIN*0..]-> |
/ :within* |
WITH RECURSIVE |
| Property match | {name:'US'} |
:name "US" |
WHERE prop->>'name' = 'US' |
| Variable syntax | (person) |
?person |
Alias in FROM/JOIN |
| Lines for emigrant query | 4 | 4 | 29 |
At first glance, CODASYL's network model looks like modern graph databases. They differ in critical ways:
| Dimension | CODASYL (1970s) | Modern Graph DBs |
|---|---|---|
| Schema | Fixed schema — specified which record types could nest within which others | No schema restriction — any vertex can connect to any other |
| Access paths | Only way to reach a record was to traverse a predefined access path | Direct lookup by ID or index on any property |
| Ordering | Children were an ordered set; insert position mattered | Vertices and edges are unordered; sorting is done at query time |
| Query style | Imperative — hard to write, broke on schema change | Also supports imperative traversals, but declarative (Cypher, SPARQL) is standard |
Datalog is older (1980s academic research) and less well-known but provides the foundation that Cypher and SPARQL build upon. Used in Datomic and Cascalog.
Instead of a triple (subject, predicate, object), Datalog writes
predicate(subject, object):
name(namerica, 'North America'). type(namerica, continent). name(usa, 'United States'). type(usa, country). within(usa, namerica). name(idaho, 'Idaho'). type(idaho, state). within(idaho, usa). name(lucy, 'Lucy'). born_in(lucy, idaho).
Queries are built from rules that define new predicates derived from data or from other rules:
within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */ within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */ within_recursive(Via, Name). migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */ born_in(Person, BornLoc), within_recursive(BornLoc, BornIn), lives_in(Person, LivingLoc), within_recursive(LivingLoc, LivingIn). ?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */
Rules can refer to other rules, including recursively. Complex queries are built up a small piece at a time — like composing functions. The Datalog approach requires a different kind of thinking: you define what is true rather than how to find the answer. The system figures out the computation.
Datalog's key insight: rules can be combined and reused across different queries. It's less convenient for one-off queries but more powerful for complex, interconnected data. The rule-based approach also makes it natural for recursive and deductive queries.
Chapter 2 gives you the framework to answer the most common opening question in system design interviews: "Would you use SQL or NoSQL for this?"
What does the data look like?
Does the schema change frequently?
What are the query patterns?
Sample interview opener:
Interviewer: "Design a social network with user profiles, friend connections, and a news
feed."
You: "I'd start with a hybrid approach. User profiles are self-contained
documents with some one-to-many relationships (education, work history) — a document DB
works well here. But friend connections and the graph traversal for 'friends of friends'
suggest a graph DB for the social graph. The news feed requires high write
throughput and low read latency — possibly a cache layer. This is polyglot
persistence in practice."
1. The hierarchical model of the 1970s (IMS) is most similar to which modern data model?
Relational model Document model (JSON/NoSQL) Network model (CODASYL)2. What is the main limitation of the document model?
No schema enforcement Slow writes Poor support for joins and many-to-many relationships3. Schema-on-read vs schema-on-write — which is analogous to dynamic type checking?
Schema-on-read Schema-on-write Neither4. In the Twitter example from Chapter 1, what was the real "load parameter" that determined the architecture?
Tweets per second Home timeline reads per second Distribution of followers per user5. How does Cypher express a variable-length traversal path?
WITH RECURSIVE -[:WITHIN*0..]-> / :within*6. What makes graph databases different from CODASYL's network model?
Both use access paths No schema restriction, direct lookup by ID/index, unordered, declarative queries Both have schemas and orderingReal world → general data model (tables/documents/graph) → storage engine → hardware
OO code ↔ tables = impedance mismatch; ORMs reduce but don't eliminate it
Document = tree (one-to-many, explicit locality, no joins needed for tree)
Relational = sets with arbitrary connections (joins, many-to-many, normalization)
IMS hierarchical (1970) = today's document model. Same strength (tree), same weakness (many-to-many)
CODASYL network model (1970s) = generalization of hierarchy (multiple parents), but imperative, fixed schema, access paths
Relational model won because: query optimizer, no access paths, add index → queries auto-improve
Schema-on-read ⇒ dynamic type checking; structure implicit, interpreted on read; good for heterogeneous data
Schema-on-write ⇒ static type checking; structure explicit, enforced on write; good for uniform data
Adding field: doc = just write new docs with field, handle old in app; relational = ALTER TABLE (fast except MySQL which copies entire table)
Document stored as continuous string → one disk seek for whole doc. Tradeoff: loads entire doc even if only small part needed; rewrite entire doc on update
Keep documents small (× large docs → wasteful partial reads)
Spanner interleaved tables = same locality in relational model
Postgres, MySQL, DB2 → JSON support with querying. RethinkDB → joins. Document + relational = hybrid future
Declarative (SQL, CSS) ⇒ specify what, not how. Optimizer chooses access path. Naturally parallel. Concise. Browser removes CSS automatically when class changes — SQL has same property
Imperative ⇒ specify every step. Hard to parallelize. Broke when order/API changes
Hybrid: declarative filter + imperative map/reduce functions. Pure functions (no side effects, no additional DB queries). Harder than SQL. MongoDB's aggregation pipeline is JSON-SQL
Moral: NoSQL often reinvents SQL in disguise. Declarative wins because optimizer can improve without rewriting queries
Vertices (ID, properties, in/out edges) + Edges (ID, tail, head, label, properties)
Neo4j, Titan, InfiniteGraph
(subject, predicate, object). Subject = vertex. Object = value (property) or vertex (edge). Predicate = key or edge label
Turtle format: _:lucy a :Person; :name "Lucy". Semicolons = same subject
URIs as predicates → internet-wide data exchange. Semantic web (overhyped). SPARQL query language
| Language | Model | Key syntax |
|---|---|---|
| Cypher | Property graph | (n)-[:EDGE*0..]->(m) |
| SPARQL | Triple-store (RDF) | ?s :edge / :edge* ?o |
| Recursive SQL | Relational | WITH RECURSIVE ... UNION ... |
| Datalog | Predicate logic | pred(X, Y) :- pred(X, Z), pred(Z, Y) |
Data shape → tree (document) / interconnected (graph) / tabular (relational)
Schema → changing/heterogeneous (schema-on-read) / stable uniform (schema-on-write)
Query pattern → whole entities (document) / variable-depth traversals (graph) / ad-hoc aggregations (relational)
Answer structure: "I'd start with a hybrid approach. Profile data is document-like → document DB. Social graph needs traversals → graph DB."
Primary source: Kleppmann, M. (2017).
Designing Data-Intensive Applications, Chapter 2: “Data Models and Query
Languages.” O'Reilly Media.
Code examples adapted from: Figures 2-1 through 2-6 in the same chapter.
Recommended supplement: Stonebraker & Hellerstein (2005). “What
Goes Around Comes Around.” Readings in Database Systems, 4th ed., MIT Press.
Questions? Ask your agent — you can follow up on any concept, quiz answer, or how this applies to a specific FAANG interview question.