Module P-9 — Specialty Datastores: Wide-Column, Graph, and Time-Series
What this module covers: Module F-14 gave you four datastore shapes and told you to default to Postgres. This is the deep comparison it deferred, for the three shapes that are genuinely different engines rather than different interfaces. For each one: the data model, the single thing it does that Postgres cannot do well, and the bill. Wide-column partition and clustering key design and its two failure modes; graph traversal and the exact point where a recursive CTE stops working; time-series partitioning, compression, continuous aggregates, and retention as a feature rather than a cron job.
Three Engines, Three Bets
Module F-14's framing was that NoSQL stores scale by removing features. That's the right lens, but it's incomplete for these three, because each removes a different feature and buys a different capability with the proceeds:
Store
What it removes
What it buys
The bet
Wide-column
Joins, ad-hoc queries, secondary index freedom
Linear write scaling, bounded p99 per partition
You know every query in advance
Graph
Horizontal write scaling, schema rigidity
Variable-depth traversal at constant cost per hop
Your value is in the edges, not the nodes
Time-series
Updates, deletes, arbitrary primary keys
Compression and time-scoped scans on ordered append-only data
Time is your dominant dimension
The third column is what people buy. The fourth column is what they're actually signing. When the bet is wrong, the store doesn't degrade gracefully — it degrades into "we cannot answer this question at all", which is a different and worse failure than "this query is slow".
Wide-Column: You Model the Query, Not the Entity
Cassandra and DynamoDB present a superficially familiar surface — tables, rows, columns, a primary key — and this familiarity is the single largest source of failed migrations. The primary key does something entirely different from Postgres's.
A wide-column primary key has two parts:
The partition key decides which node the data lives on. It's hashed (Module P-7) to a position on a ring or a partition; the store never looks anywhere else. A query without the partition key has no idea which of your 40 nodes to ask.
The clustering key (Cassandra) or sort key (DynamoDB) decides the physical order of rows inside that partition. Rows are stored sorted on disk by this key, which is what makes a range scan inside one partition a single sequential read (Module F-2).
sql
That double-parenthesis detail is not syntax trivia. PRIMARY KEY ((device_id, bucket), ts) gives you one partition per device per month. PRIMARY KEY (device_id, bucket, ts) gives you one partition per device, forever, which is the unbounded-partition failure below.
Everything downstream follows from one rule: you can only efficiently query by full partition key, plus optionally a prefix of the clustering key. That's the leftmost-prefix rule from Module F-13, except in Postgres violating it costs you an index scan and here it costs you the ability to run the query. Cassandra will refuse outright unless you add ALLOW FILTERING, which asks every node to scan every partition — a phrase that should read as "please turn this query into an incident".
So modelling runs backwards from what it does in Postgres:
Postgres
Wide-column
Model the entities, normalise, then find queries the schema supports
Enumerate the queries, then build one table per query
One table per entity; joins compose them
One table per access pattern; the same fact is stored in each
Add an index when a new query appears
Add a table and backfill it when a new query appears
Schema change is a migration
Schema change is a migration plus a rewrite of the data
Analogy: a wide-column store is a warehouse with no aisles — only a loading dock and pre-packed pallets. Anything you might ship must have had a pallet built for it in advance, and an item that belongs in three orders is physically boxed three times. You can ship a packed pallet in seconds and it never gets slower as the warehouse grows. An order nobody anticipated cannot be filled at all; you shut the dock and repack.
Storing the same data three times is the design, not a smell
If your access patterns are "readings for a device in a time range", "all alerts for a customer", and "devices currently in fault state", that is three tables, each written on every relevant event. In DynamoDB the equivalent is single-table design: one physical table where PK/SK are opaque composite strings (DEVICE#123 / READING#2026-07-29T10:00Z) and several logical entity types share it, so that one Query returns a whole object graph in a single round trip.
This is the trade you're making explicit: you have replaced read-time joins with write-time fan-out. The cost is that a single logical event becomes N writes with no transaction spanning them in the general case, so the tables can disagree. DynamoDB offers TransactWriteItems across items and tables with a bounded item count, and Cassandra offers logged batches that guarantee eventual application of all statements but not isolation. Neither gives you Postgres's guarantee. Partial fan-out is a normal state you have to design for — usually by making every write idempotent and replayable (Module P-16) and driving the fan-out from one authoritative event rather than from the request handler.
There are no joins. Not "joins are slow" — there is no join operator. If two access patterns need combining, you combine them in application code, which means N round trips and the client-side N+1 problem from Module F-8, or you pre-combine at write time. Also note what disappears with the join: foreign keys, CHECK constraints, and unique constraints across items. Every guarantee Module F-13 argued should live in the database moves into application code, where F-13 listed the four ways it fails.
The Two Ways a Partition Key Kills You
Both failures come from the same fact — a partition is a physical unit that lives on one set of replicas and cannot be split — and both are silent for months.
Unbounded partitions.PRIMARY KEY (device_id, ts) looks reasonable and grows forever. A device emitting a reading every 10 seconds produces roughly 260,000 rows a year in one partition. Commonly cited Cassandra guidance is to keep partitions under roughly 100 MB and 100,000 rows; past that, reads that touch the whole partition need more memory, compaction has to rewrite an ever-larger unit, and repair streams it wholesale. The symptom arrives as p99 read latency climbing over months with no deployment to blame, then timeouts on the oldest, largest devices first. DynamoDB draws a harder line: an item collection — all items sharing a partition key — cannot exceed 10 GB if the table has a local secondary index, and writes simply start failing.
The fix is bucketing: fold a time or numeric bucket into the partition key, as in the bucket column above. The cost is that a query spanning three months is now three parallel partition reads that the application stitches together, and a query with no time bound becomes unanswerable without knowing which buckets exist.
Hot partitions. Traffic is never uniform. A partition key of country gives you a partition holding 40% of your data and taking 40% of your writes — on one replica set, while the other 39 nodes idle. Adding nodes does nothing: consistent hashing (Module P-7) redistributes partitions, and a hot partition is indivisible. DynamoDB's per-partition ceilings are published as roughly 3,000 read units and 1,000 write units per second; adaptive capacity will shift provisioned throughput toward a hot partition, but it cannot exceed the physical ceiling, so a single celebrity key throttles no matter how much capacity you buy for the table.
Why this matters in production: Module F-14 said the partition key is the hardest decision to reverse. This is the mechanism. Changing it means rewriting every row into a new table, dual-writing during the migration, and backfilling potentially terabytes — while the wrong key is actively paging you. Spend the design time on the key distribution: check the actual cardinality and the actual skew of the candidate column against production data before writing any code, and if the natural key is skewed, add a synthetic suffix (customer_id#shard_n, write to a random shard, read all shards in parallel) and accept the fan-out read cost up front.
DynamoDB GSIs are a second table you don't get to write to
A global secondary index lets you query by a different partition key — Query by email when the table's key is user_id. It looks like adding an index in Postgres. It isn't. A GSI is a separate, asynchronously-maintained projection:
It is eventually consistent, with no opt-out.ConsistentRead: true works against the base table and against a local secondary index; it is not available on a GSI. A write followed immediately by a GSI query can legitimately return the old value or nothing at all. Read-after-write flows — "create the order, then look it up by reference number" — break intermittently and only under load.
It has its own throughput and its own throttling, and here is the part that surprises people: if a GSI is throttled, the base table's writes are affected, because the write cannot be accepted if it cannot be propagated. An index you added for one rare admin query becomes an availability dependency for your main write path.
It projects only chosen attributes. A query needing an unprojected attribute has to fetch the base item — the same heap-fetch penalty as a non-covering index in Module F-13, except each fetch is a network round trip rather than a page read.
Cassandra's equivalent trap is different in shape: its secondary indexes are local to each node, so a query using one becomes scatter-gather across the whole cluster and gets slower as you add nodes. Cassandra's materialised views are the closer analogue to a GSI, and are likewise asynchronous.
Postgres index
DynamoDB LSI
DynamoDB GSI
Cassandra secondary index
Consistency with base data
Transactional
Strong available
Eventual only
Node-local, eventual
Partition key
n/a
Same as table
Different
Same as table
Query cost shape
Local to shard
Local to partition
Local to partition
Fan-out to all nodes
Can be added later
Yes
No — table creation only
Yes, with backfill
Yes
Failure mode
Slow writes
Item collection 10 GB cap
Throttle propagates to base writes
Cluster-wide scatter-gather
Graph: Where a Recursive CTE Stops Being Enough
Every graph query you'd write in Postgres works. The question is at what depth it stops finishing.
Postgres models a graph as an edge table and traverses it with a recursive CTE:
sql
Each hop is an index lookup per row in the current frontier. With an average out-degree of 50, the frontier is 50, then 2,500, then 125,000, then 6.25 million index lookups — each one a potentially random page read, and the intermediate result set has to be materialised and deduplicated. This is Module F-2's random-access penalty compounding geometrically. Two hops is a fast query. Four hops on a social-scale graph is a query that consumes a connection for minutes, which by Module F-13's cascade is how one traversal takes down a service.
A native graph store attacks exactly this. Neo4j's mechanism is index-free adjacency: a node record holds direct pointers to its relationship records, and each relationship points at the next one. Traversing an edge is a pointer dereference, not an index probe. The cost of one hop is therefore proportional to the degree of the node you're standing on, and independent of total graph size. A 6-hop query on a 10-billion-edge graph costs the same per hop as on a 1-million-edge graph, which is not true of the join.
The query language reflects it — depth becomes a parameter rather than a structural rewrite:
cypher
Writing that query as SQL is a page of recursive CTE, and changing 6 to 8 in the SQL version changes its runtime by orders of magnitude while the Cypher version changes it linearly-ish in the frontier.
Traversal depth
Postgres recursive CTE
Native graph store
1 hop
Index lookup. Postgres is fine, probably faster
Pointer walk
2 hops
Still fine with the right composite index
Fine
3–4 hops
Degrades sharply with out-degree; materialises large intermediates
Fine
5+ hops, or shortest-path, or "find the cycle"
Effectively unusable at scale
The reason the store exists
Where a graph store earns its keep, and where it's overkill
It wins when the shape of the connections is the answer, and the depth isn't known when you write the query:
Fraud rings. "Is this new account connected to a known-fraudulent one through any chain of shared devices, addresses, payment instruments, or referrals?" Nobody knows whether the chain is 2 or 7 long, and the ring structure — a cycle, a hub — is the signal. Detecting it in SQL means guessing a depth.
Permissions hierarchies. "Does Priya have read access to this document?" where access flows through nested groups, folder inheritance, org-chart delegation, and explicit grants. Variable depth, and the answer must be exact. (Module A-13 covers the authorisation model itself.)
Recommendations over relationships. "People who bought what you bought also bought" is two hops and a GROUP BY — Postgres handles it. "Products connected to your purchases through shared categories, co-purchase edges, and brand affinity, weighted by path length" is a traversal.
It's overkill when:
Your depth is fixed and small. A manager_id self-reference on an org table read three levels deep is a recursive CTE with a good index. Do not add a database for it.
Your workload is really a filtered aggregate over nodes. "Count users in region X created last month" is a relational query. Graph stores do not have the columnar or index machinery to do this well, and you will have made your most common query worse to speed up a rare one.
Your write volume needs horizontal scaling. This is the honest cost. Index-free adjacency depends on locality, and locality is what sharding destroys — a traversal crossing shards becomes a network hop per edge, which is the thing you paid to avoid. Neo4j scales reads with follower replicas but routes writes to a single leader, so write throughput is a vertical-scaling problem (Module F-9). If your bottleneck is write volume rather than traversal depth, a graph store is the wrong direction.
You need it for one feature. Which is the usual case, and points at the pattern in the closing section rather than at a migration.
Why this matters in production: the deceptive thing about graph workloads is that they pass code review and pass staging. A 4-hop recursive CTE on a 10,000-row development graph returns in 30 ms. The out-degree in production is 40× higher and the query is a different algorithm at that size. Load-test traversals against production-shaped degree distributions (Module O-5), not production-shaped row counts — the row count is not the variable that matters.
Time-Series: The One Workload Whose Write Pattern Is Known in Advance
Metrics, sensor readings, trades, event counters. The workload has properties no other workload has, and every design choice in a time-series store falls out of them:
Writes are append-only, and almost always at or near the current time.
Rows are immutable once written. Updates are corrections, and rare. Deletes happen in bulk by age, never by row.
Reads are almost always time-bounded, and recent data is read far more than old data.
Old data is read at lower resolution. Nobody wants per-second values from eight months ago; they want hourly averages.
Adjacent values in the same series are highly similar, which is the property that makes compression extraordinary.
Postgres can absolutely store this — and running it naively is how you find out what the specialty features are for. The naive version is one wide table with a ts column, a B-tree on (series_id, ts), and a nightly DELETE. It fails in a specific, recognisable order: the index grows past memory so writes start hitting random pages, the nightly DELETE creates millions of dead tuples that autovacuum can't keep up with (Module F-13), the table's physical size stops shrinking even as logical retention holds steady, and the dashboard query that averages a month of data scans every raw row every time someone loads the page.
Four features answer those four failures.
1. Time-based partitioning. TimescaleDB's hypertable is a Postgres table transparently split into chunks by time interval; native Postgres declarative partitioning does the same with more manual work. The wins are concrete: each chunk's indexes are small enough to stay in memory, and since writes cluster at the current time, only the newest chunk is hot — so the write path is sequential-ish rather than scattered across a huge index. Queries with a time predicate skip whole chunks at plan time.
2. Retention as a metadata operation, not a DELETE. This is the feature that most justifies the store:
sql
A DELETE of 90 days of rows rewrites visibility information for every one of them and leaves the space for autovacuum to reclaim. Dropping a chunk unlinks files. Same intent, entirely different cost — and the difference between a retention policy that runs in 40 ms and one that is itself the cause of your monthly performance incident.
3. Columnar compression. Once a chunk is old enough that nothing writes to it, the store can rewrite it in a columnar layout: values from the same column batched together, then delta-encoded and compressed. Because consecutive readings from one sensor barely differ, and timestamps advance by a near-constant interval, the compressible redundancy is enormous. Reported ratios vary widely with the data — roughly 5× to 20× is a reasonable expectation for typical numeric metrics, better for slowly-changing values, worse for high-entropy ones. The cost is real and specific: compressed chunks are expensive or restricted to modify row-by-row, so compression is a commitment that this data is now historical. Configure it wrong — compressing chunks that still receive late-arriving data — and backfill becomes painful.
4. Continuous aggregates and downsampling. A continuous aggregate is an incrementally-refreshed materialised view over a hypertable: it stores hourly rollups and updates only the buckets touched by new data, rather than recomputing the whole view.
sql
That last comment is the whole point: retention and resolution become independent knobs. You keep raw data as long as you need to debug, and rollups as long as you need to report — and the rollup is what your dashboards read, so a "last 30 days" panel touches 720 rows per device rather than 260,000.
The other two engines in this space make different trades worth knowing:
TimescaleDB
InfluxDB
Prometheus
Model
Postgres tables + chunks
Measurements with tags (indexed) and fields (not)
Metric name + label set, pull-scraped
Query
SQL, plus joins to relational tables
Its own query language
PromQL — built for rate/aggregation over labels
You get
Everything Postgres has, including constraints and joins
Purpose-built ingest and retention
Alerting, service discovery, recording rules
Signature failure
Compression misconfigured against late data
Tag cardinality explosion — a high-cardinality tag creates a series per value and blows up memory
Label cardinality explosion, and it is not a durable store of record
Don't use it for
—
Anything needing relational integrity
Billing or anything requiring exactness; it drops data to stay up
The cardinality warning is the one to internalise, because it is the time-series equivalent of the hot partition. A series is identified by its full tag or label set, so cardinality is the product of the distinct values of every tag. Adding user_id as a Prometheus label on a metric with 1 million users does not add a label — it adds a million series, each with its own index entry and in-memory chunk. The store falls over, and the fix is a schema change to something already being scraped. Prometheus is also explicitly not a durable long-term store: it favours availability over completeness and will drop samples rather than block, so it is the wrong home for anything you'll invoice against.
Each of These Is a Bet That One Access Pattern Dominates
Module F-14's discipline applies unchanged, and it's what turns this module from a shopping list into a decision. Every specialty store here is fast because it made one access pattern cheap by making the others expensive or impossible. So the question is never "is Cassandra good" — it's does one access pattern dominate this data enough that I want the others to be hard?
Three costs come attached to every answer of "yes":
1. Operational surface. A second datastore means a second thing to back up and test restores for, a second upgrade path, a second failure mode your on-call rotation must learn, a second set of client libraries and connection pools, a second capacity model, and a second line on the invoice (Module O-8). Cassandra in particular needs compaction strategy, repair scheduling, and tombstone management understood by whoever is paged at 3 am — none of which existed as concepts in your Postgres estate. The honest comparison is not "graph query 200× faster" but "graph query 200× faster, against one more system to keep alive".
2. The dual-write question, which F-14 named and which never goes away. If the authoritative record is in Postgres and a derived copy is in the specialty store, something must keep them in agreement. Writing to both from your request handler is the dual-write problem: the second write can fail after the first succeeded, and now the stores disagree with no bounded staleness and no way to tell which is right. The available answers are all the same answers as in F-14 — make the specialty store authoritative for its slice, or drive it from the database's own log via change data capture (Module P-23) with an outbox where you need transactional guarantees (Module A-3). What is not an answer is two writes and a retry.
3. Split-brain in querying. Once data lives in two shapes, some questions require both — and there is no join across them. You'll either denormalise further, run two queries and combine in application code, or discover that a plausible product request is now architecturally expensive. This cost is invisible at design time and shows up as feature velocity a year later.
Which yields a practical order of operations that F-14 would recognise:
Measure the query in Postgres first. With the right composite index, partitioning, and EXPLAIN ANALYZE (Module F-13), a surprising fraction of "we need Cassandra" turns out to be "we need the leading column of that index changed".
If it's a time-series problem, reach for the Postgres extension before a new engine. TimescaleDB gives you the four features above without giving up SQL, joins, or constraints — a strictly smaller bet.
Scope the specialty store to one slice, with a clear boundary and a named owner of the sync mechanism, rather than migrating a domain.
Write down the bet. "We are betting reads are always by (customer_id, month). If a query by region becomes important, the cost is a new table plus a full backfill." A future engineer will need that sentence.
Where This Shows Up in Your Stack
PostgreSQL: it is the credible competitor to all three. Declarative partitioning or TimescaleDB covers time-series; WITH RECURSIVE covers shallow graphs; JSONB plus a good composite index covers a lot of document and wide-row work; and pg_trgm or full-text covers what people reach for Elasticsearch to do (Module P-20). Exhaust it before adding an engine — and know its actual limits: writes with fsync-on-commit land around 1K–5K TPS on one node, which is the number that legitimately sends people to a wide-column store.
Node.js: every specialty client brings its own pool with its own sizing, and Module P-4's arithmetic now runs twice. Cassandra's driver is topology-aware and holds connections to many nodes, so the per-instance connection count is much higher than a single Postgres pool — a serverless deployment that opens both will exhaust something (Module F-10).
Redis: frequently the actual answer to "we need a specialty store". A leaderboard is a sorted set, a recent-activity feed is a capped list, a per-minute counter is INCR with expiry — 100K+ simple ops/s from one instance, and no new durable system to operate. If the specialty access pattern is small, hot, and regenerable, Redis is a smaller bet than a new database (Module P-12).
Prometheus / your metrics pipeline: you are already running a time-series database, and the cardinality rule above governs it. Before adding a label, multiply out the series count — this is the most common self-inflicted time-series outage in a normal stack.
Summary
Each specialty store removes a feature to buy a capability — wide-column trades joins and ad-hoc queries for linear write scaling, graph trades horizontal write scaling for depth-independent traversal, time-series trades mutability for compression and time-scoped scans. When the bet is wrong you can't answer the question at all, which is worse than slow.
Wide-column modelling runs backwards from queries. Partition key selects the node, clustering key orders rows within it, and you can only query by full partition key plus a clustering prefix. ALLOW FILTERING is an incident.
Storing the same fact in three tables is the intended design — you've swapped read-time joins for write-time fan-out, and the cost is N writes with no cross-table transaction, so idempotent replay from one authoritative event is required.
Two partition-key failures, both silent for months: unbounded partitions (fix with time bucketing, at the cost of multi-partition reads) and hot partitions (adding nodes doesn't help, because a partition is indivisible). This is why F-14 called the partition key the hardest decision to reverse.
A DynamoDB GSI is an asynchronous projection, not an index. Eventually consistent with no ConsistentRead option, separately throttled — and GSI throttling propagates back into base-table writes. Cassandra's local secondary indexes fail differently: scatter-gather that worsens as the cluster grows.
A recursive CTE works to about two hops and degrades geometrically with out-degree. Index-free adjacency makes a hop a pointer dereference whose cost is independent of graph size, which is why fraud rings, nested permissions, and path-weighted recommendations belong in a graph store — and why fixed-depth hierarchies and node aggregates do not.
Time-series design follows from append-only, immutable, time-bounded, resolution-decaying data. Chunking keeps the hot index small; retention as a chunk DROP avoids the vacuum debt a bulk DELETE creates; columnar compression of roughly 5–20× costs you the ability to modify old chunks; continuous aggregates make retention and resolution independent knobs.
Cardinality is the time-series hot partition. Series count is the product of tag or label values, so one high-cardinality label multiplies your series and your memory. Prometheus additionally is not a store of record — it drops data to stay available.
Every addition costs operational surface, a dual-write question, and the inability to join across stores. Measure in Postgres first, prefer an extension to a new engine, scope the store to one slice, and write the bet down with the cost of being wrong.
The next module, CAP and PACELC, takes the distributed stores you've just met and asks the question underneath their configuration flags: when the network partitions, what does each of them give up, and — the part CAP omits and PACELC adds — what does it give up during normal operation, when there is no partition at all and latency is the thing being traded against consistency.
Knowledge Check
A fleet-telemetry service uses Cassandra with PRIMARY KEY (device_id, ts). It has run well for eight months. Now reads for the longest-installed devices time out, compaction takes far longer than it used to, and repairs stream huge amounts of data — with no recent deployment. What is happening and what is the fix?
A DynamoDB-backed order service writes an order keyed by order_id, then immediately queries a GSI on reference_number to return the created order to the client. It works in testing and intermittently returns "not found" under production load. Separately, base-table writes have begun to throttle after an infrequently-used GSI was added for an admin screen. What explains both?
A Postgres table stores 30 days of sensor readings. A nightly DELETE FROM readings WHERE ts < now() - interval '30 days' keeps the row count flat, yet the table's on-disk size keeps growing, autovacuum never catches up, and the dashboard query averaging a month of data gets slower every week. What does the module prescribe?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- Cassandra. Read this as: "one physical row per device_id+month,-- containing all readings for that month in descending time order."CREATETABLE readings_by_device ( device_id uuid, bucket text,-- 'YYYY-MM'; part of the partition key, see below ts timestamp, celsius float,PRIMARYKEY((device_id, bucket), ts)-- ^^ double parentheses = composite PARTITION key. Single parens-- would make bucket a clustering column, which is the bug.)WITH CLUSTERING ORDERBY(ts DESC);
-- Friends-of-friends up to 4 hops. Correct, and fine at 2 hops.WITH RECURSIVE reachable(id, depth)AS(SELECT to_id,1FROM edges WHERE from_id = $1UNION-- UNION, not UNION ALL: dedupes the frontier,SELECT e.to_id, r.depth +1-- which is the only thing keeping cycles finiteFROM edges e JOIN reachable r ON e.from_id = r.id
WHERE r.depth <4)SELECTDISTINCT id FROM reachable;
// Shortest path through accounts that shared a device or an address,// up to 6 hops. Depth is one character to change.MATCH path =shortestPath((a:Account{id:$from})-[:SHARED_DEVICE|SHARED_ADDRESS*1..6]-(b:Account{id:$to}))RETURN path
-- Hypertable partitioned into 1-day chunks.SELECT create_hypertable('readings','ts', chunk_time_interval =>INTERVAL'1 day');-- Retention: drops whole chunks whose time range has fully expired.-- This is a DROP of a physical table, not a DELETE of rows — no dead-- tuples, no vacuum debt, no index bloat, and it returns in milliseconds.SELECT add_retention_policy('readings',INTERVAL'90 days');
CREATE MATERIALIZED VIEW readings_hourly
WITH(timescaledb.continuous)ASSELECT device_id, time_bucket('1 hour', ts)AShour,avg(celsius),max(celsius),count(*)FROM readings
GROUPBY device_id,hour;-- The dashboard reads this. Raw data can then be dropped at 7 days-- while the hourly rollup is kept for two years at a fraction of the size.