Module P-9·34 min read

Cassandra and DynamoDB partition-key design, Neo4j traversals vs recursive SQL, TSDB downsampling and retention — what each one earns over Postgres.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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:

StoreWhat it removesWhat it buysThe bet
Wide-columnJoins, ad-hoc queries, secondary index freedomLinear write scaling, bounded p99 per partitionYou know every query in advance
GraphHorizontal write scaling, schema rigidityVariable-depth traversal at constant cost per hopYour value is in the edges, not the nodes
Time-seriesUpdates, deletes, arbitrary primary keysCompression and time-scoped scans on ordered append-only dataTime 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:

PostgresWide-column
Model the entities, normalise, then find queries the schema supportsEnumerate the queries, then build one table per query
One table per entity; joins compose themOne table per access pattern; the same fact is stored in each
Add an index when a new query appearsAdd a table and backfill it when a new query appears
Schema change is a migrationSchema 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.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.