Lesson 2: Data Models and Query Languages

Lesson 0002 — 55 min read

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.

The Layered Abstraction of Data Models

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.


Relational Model vs Document Model

The Relational Model

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.

The Birth of NoSQL

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.

The Object-Relational Mismatch (Impedance Mismatch)

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.

Three ways to model this in SQL:

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.

Many-to-One and Many-to-Many Relationships

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.

Are Document Databases Repeating History?

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:

The Network Model (CODASYL)

The Relational Model

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.


Relational vs Document: Practical Decision Factors

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

Schema Flexibility: Schema-on-Read vs Schema-on-Write

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.

Data Locality for Queries

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.

Convergence of Document and Relational

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.


Query Languages for Data

Declarative vs Imperative

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

The CSS analogy

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

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.


Graph-Like Data Models

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.

Property Graphs

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:

  1. Any vertex can connect to any other vertex — no schema restriction
  2. Efficient traversal both forward and backward (indexes on tail and head)
  3. Different edge labels let you store different relationship types in one graph

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 Query Language (Neo4j)

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).

Graph Queries in SQL (Recursive CTEs)

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.

Triple-Stores and SPARQL

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.

Turtle format (human-readable RDF)

@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 and the Semantic Web

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 Query Language

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

Graph Databases vs the Network Model (CODASYL)

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

The Foundation: Datalog

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.


Interview Synthesis

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?"

Your decision tree:

  1. What does the data look like?

  2. Does the schema change frequently?

  3. 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."


Quick Retrieval Quiz

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 relationships

3. Schema-on-read vs schema-on-write — which is analogous to dynamic type checking?

Schema-on-read Schema-on-write Neither

4. 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 user

5. 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 ordering

Data Model Layers :-

Real world general data model (tables/documents/graph) storage engine hardware

Relational v/s Document :-

Object-relational mismatch :-

OO code ↔ tables = impedance mismatch; ORMs reduce but don't eliminate it

Key difference :-

Document = tree (one-to-many, explicit locality, no joins needed for tree)

Relational = sets with arbitrary connections (joins, many-to-many, normalization)

History repeating :-

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 :-

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)

Locality :-

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

Convergence :-

Postgres, MySQL, DB2 → JSON support with querying. RethinkDB → joins. Document + relational = hybrid future

Declarative v/s Imperative :-

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

MapReduce :-

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

Graph Models :-

Property graph :-

Vertices (ID, properties, in/out edges) + Edges (ID, tail, head, label, properties)

Neo4j, Titan, InfiniteGraph

Triple-store :-

(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

RDF :-

URIs as predicates → internet-wide data exchange. Semantic web (overhyped). SPARQL query language

Query Languages Comparison :-

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)

Interview Decision Tree :-

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.

← Lesson 1: Reliability, Scalability, Maintainability Lesson 3: Storage and Retrieval →