TS-44: Non-relational (NoSQL) databases

This technical standard covers non-relational databases: the data models they offer (key-value, document, wide-column, and graph), how to model data for them, the consistency and replication trade-offs specific to NoSQL, and how to choose between a NoSQL engine and TS-43: Relational databases and SQL — or between different NoSQL engines — for a given service. It does not repeat the ACID/BASE and CAP-theorem treatment already covered in TS-43, nor the general distributed-data and caching guidance in TS-46: Distributed data and caching; it builds on both. For the mechanics of moving data between database engines, see TS-45: Data migrations.

Data models

"NoSQL" is not one technology; it is a label for databases that do not use the relational model. The unifying trait is that they trade the relational model’s fixed, joinable schema for one of a few alternative data models, each optimized for a different access pattern. Choosing a NoSQL database is choosing a data model, not just a vendor.

Key-value stores

A key-value store maps opaque keys to opaque values. The database has no knowledge of the value’s internal structure — it is a blob to be stored and retrieved by key. Reads and writes by key are typically O(1) and extremely fast, because the engine does no parsing, indexing, or joining of the value’s contents.

This makes key-value stores well suited to caching, session storage, and any workload where every access is a lookup by a single known identifier. It makes them poorly suited to querying by any attribute other than the key — there is usually no equivalent of a WHERE clause over the value’s fields without either scanning every key or maintaining a separate index by hand. Redis and Amazon DynamoDB (in its simplest usage) are representative engines.

Document databases

A document database stores self-contained, semi-structured records — typically JSON or BSON — under a key. Unlike a key-value store, the engine understands the document’s internal structure well enough to index and query on specific fields within it, not just the top-level key.

A document typically represents one aggregate: an order together with its line items, a user profile together with its addresses. Related data that would be normalized into separate tables in a relational schema is instead embedded directly in the document, so that a single read retrieves the whole aggregate without a join. See Embedding vs referencing for the trade-off this introduces. MongoDB and Couchbase are representative engines.

Wide-column stores

A wide-column store organizes data into rows identified by a key, where each row can have a different, sparse set of columns grouped into column families. It resembles a relational table stretched to allow millions of rows and a variable, per-row column set, optimized for very high write throughput and horizontal scale across many nodes.

Wide-column stores are typically chosen for time-series data, event logging, and other write-heavy workloads with a well-known query pattern — the schema (or its absence) is usually designed around exactly the queries the application will run, not around a normalized model of the domain. Apache Cassandra, Apache HBase, and ScyllaDB are representative engines.

Graph databases

A graph database stores nodes and the relationships (edges) between them as first-class entities, and is optimized for traversing those relationships — answering questions like "what is connected to this node, and how" — rather than for aggregating or filtering large sets of records. A relational database can model a graph with a join table, but traversing more than one or two hops deep requires a chain of joins that gets expensive quickly; a graph database is built to make that traversal cheap regardless of depth.

Graph databases suit domains that are fundamentally about relationships — social networks, recommendation engines, fraud detection, permission and access-control graphs. Neo4j and Amazon Neptune are representative engines.

Search engines

A search engine indexes documents for full-text search, relevance ranking, and faceted filtering, rather than for transactional reads and writes. It is included here because it is commonly deployed alongside a primary datastore as a specialized secondary index, not because it is normally used as a system of record. Elasticsearch and OpenSearch are representative engines.

Do not use a search engine as the sole store for data the application cannot afford to lose or reconstruct — see Selecting a database.

Schema and modeling

A relational schema is designed around the data’s structure, and queries are written afterward to fit it. A NoSQL schema SHOULD be designed the other way around: start from the application’s known query patterns, and shape the data to answer them directly. This inversion is the single biggest mental shift required to model data well in a NoSQL database, and the most common source of a poor fit when a team applies relational habits unchanged.

Query-first design

Before modeling any entity, enumerate the queries the application actually needs to run against it, and how often each runs. A schema optimized for a read that happens once a day at the expense of one that happens a thousand times a second is a bad trade even if it is more "normalized". Most NoSQL engines do not offer arbitrary ad-hoc querying with the flexibility of SQL — what an index or a document layout does not anticipate, it typically cannot answer efficiently at all, rather than merely slowly.

Embedding vs referencing

The central schema-design decision in a document database is whether related data is embedded inside a parent document or referenced by ID in a separate document, mirroring a foreign key.

  • Embed when the related data is always read together with its parent, has a bounded size, and does not need to be queried independently. An order’s line items are a good candidate: they are meaningless without the order, always displayed with it, and bounded in count.
  • Reference when the related data is large, unbounded, shared across many parents, or needs to be queried, updated, or read independently of its parent. A product catalog entry referenced by many orders is a good candidate: embedding a full copy into every order that references it would duplicate the data and require updating every copy when the product changes.

Embedding trades write complexity and duplication for read performance; referencing trades read performance (an extra lookup, or an application-level join the database will not perform for you) for a single source of truth. Most real schemas mix both within the same document.

Denormalization and duplication

Deliberate duplication of data across documents or rows is a normal, expected pattern in NoSQL modeling, not a mistake to be normalized away. A user’s display name copied onto every comment they have posted avoids a lookup on every comment read, at the cost of a multi-record update if the user later changes their name.

Duplication MUST be a deliberate trade-off, made because the read pattern that benefits from it is known to matter, not a default. Every duplicated field is a place that update logic can silently drift out of sync if it is not kept consistent by the application itself.

Aggregate boundaries

An aggregate is the unit of data that is read and written atomically — the document in a document database, the row (and its column family) in a wide-column store. Most NoSQL engines guarantee atomicity only within a single aggregate, not across multiple aggregates in the same write. Modeling the aggregate boundary correctly is therefore also a consistency decision, not only a performance one: data that must be updated together atomically belongs in the same aggregate. See Consistency and replication for what happens when it cannot be.

Access patterns over normalization

Where a relational schema aims to represent each fact exactly once and derive every view from joins, a NoSQL schema commonly maintains multiple, differently-shaped copies of the same underlying data — one per access pattern. A wide-column store used for both "get all orders for a customer" and "get all orders on a given day" may maintain two tables, each keyed and sorted for one of those two queries, both populated from the same writes. This is normal design in this data model, not duplication to be eliminated.

For the equivalent relational schema-design guidance — normalization, naming conventions, and data typing for a joinable, normalized model — see TS-43: Relational databases and SQL.

Consistency and replication

The ACID-vs-BASE trade-off and the CAP theorem that underlies it are covered in depth in TS-43: Relational databases and SQL — see its "ACID vs BASE" section — and apply unchanged to NoSQL databases. This section covers what that trade-off means in NoSQL practice, where it is usually a first-class, per-query configuration choice rather than a fixed property of the engine.

Consistency is usually tunable, not fixed

Where a relational database offers a small set of named isolation levels that apply uniformly to transactions, many NoSQL databases expose consistency as a tunable, per-operation setting. A wide-column store commonly lets each read or write specify how many replicas must acknowledge before the operation returns — a lower number is faster and more available under a partition; a higher number is more consistent, at the cost of latency and availability. Strong consistency and eventual consistency are therefore not a single up-front choice about the database, but a choice the application can make differently per query, based on what that specific read or write actually requires.

Read-your-writes and session guarantees

A common practical requirement is not full strong consistency, but a session guarantee: a client that just wrote a value SHOULD see that value on its own subsequent read, even if a concurrent client elsewhere might briefly see the old value. Most NoSQL databases can provide this — reading from the same replica a write went to, or from a quorum large enough to guarantee overlap — without paying for cluster-wide strong consistency on every read. Reach for this before reaching for full strong consistency: it solves the case users actually notice (their own write appearing to vanish) at a fraction of the cost.

Conflict resolution

Where writes can happen concurrently on different replicas — common in a multi-region, eventually-consistent deployment — the database needs a way to resolve two writes to the same key that arrive out of order or at the same time. Common strategies, in increasing order of application involvement:

  • Last-write-wins. The write with the latest timestamp wins; the other is discarded. Simple and requires no application logic, but silently loses data — acceptable only where losing a losing write is genuinely harmless.
  • Vector clocks. The database tracks enough causal-ordering metadata to detect when two writes are genuinely concurrent (neither is a descendant of the other) rather than just out of order, and surfaces the conflict to the application to resolve instead of guessing.
  • Application-level merge. The application supplies a merge function — for example, union the two versions of a set, or sum two counters — so concurrent writes combine deterministically instead of one overwriting the other.

A schema SHOULD be designed so that conflicts are structurally rare or mergeable — for example, using an append-only or counter-like field where a concurrent update is a well-defined merge — rather than relying on last-write-wins for data that would be genuinely lost.

Replication topology

NoSQL databases built for horizontal scale typically replicate data across multiple nodes for both availability and read throughput, using one of two broad topologies:

  • Leader-follower. One replica accepts writes and propagates them to followers, which serve reads. Simple to reason about, but the leader is a bottleneck for write throughput and a single point of failure until a new leader is elected.
  • Multi-leader / leaderless. Any replica can accept a write, which is then propagated to the others. Removes the single-leader bottleneck and tolerates a partition better, at the cost of needing the conflict resolution described above, since two replicas can now legitimately accept conflicting concurrent writes.

For the broader distributed-systems treatment of replication, caching, and staleness — which applies regardless of whether the underlying store is relational or NoSQL — see TS-46: Distributed data and caching.

Selecting a database

Polyglot persistence

A system of any real size is not obligated to use one database technology throughout. Polyglot persistence is the practice of choosing a different database, per service or per component, based on that component’s actual access patterns, rather than standardizing on a single engine everywhere for consistency’s own sake. A service that does high-throughput key lookups is a poor fit for a database chosen because another, unrelated service in the same system needed complex relational joins, and vice versa.

Allegro, migrating a service’s storage from Cassandra to MongoDB, completed the switch in a two-week sprint with no client-visible change, because the service’s external API was already decoupled from its storage implementation (Allegro Tech 2024 — see the "References" section at the end of this document, and TS-5: Application architecture for the architectural decoupling that makes this kind of switch cheap). The lesson generalizes: the choice of database is a per-service implementation detail, not a system-wide architectural commitment, provided the service’s persistence layer is not leaked through its API.

Polyglot persistence has a real cost, which MUST be weighed against the per-service fit it buys: each additional database technology in production is another engine to operate, monitor, back up, secure, and staff for. A system SHOULD NOT introduce a new database technology for a marginal fit improvement in one service if an existing, already-operated database in the system is an adequate fit. Reach for a new engine when the access pattern is a poor fit for what is already running, not merely a different one.

When NoSQL fits, and when it doesn’t

NoSQL is a good fit when at least one of the following holds:

  • No natural fixed schema. Different records genuinely have different shapes — for example, a product catalog spanning categories with unrelated sets of attributes.
  • A known access pattern. The dominant access pattern is a lookup by a known key (key-value, document) or a known, well-understood query (wide-column), and that pattern is known in advance rather than ad hoc.
  • A real horizontal write-scale requirement. Scale beyond what a single relational primary can sustain is a real, current requirement — not a hypothetical future one. See NoSQL database selection criteria below.
  • A relationship-shaped domain. A graph database’s fit for social, recommendation, or permission-graph data, as described in Data models.

A relational database remains the better default when the data has a stable, well-understood shape; when the application needs ad hoc queries across arbitrary combinations of fields, which most NoSQL engines answer poorly without an index built in advance for that exact query; or when multi-record transactional consistency across different entities is a hard requirement, which TS-43: Relational databases and SQL's ACID guarantees provide natively and most NoSQL engines do not, or only partially. Do not choose NoSQL by default, or because it is perceived as more modern or more scalable in the abstract — choose it because a specific access pattern or scale requirement genuinely does not fit the relational model. See TS-5: Application architecture for the broader principle that a technology choice SHOULD be driven by the requirement it serves, not adopted speculatively.

NoSQL database selection criteria

Having decided that a NoSQL data model fits, the choice of specific engine within that data model SHOULD be driven by the following criteria, roughly in order of how hard each is to change later:

  1. Consistency model. Does the engine offer the read-your-writes or quorum guarantees the application needs (see Consistency and replication), and can that be tuned per operation, or is it fixed cluster-wide?
  2. Scalability and sharding model. How does the engine distribute data and load across nodes as the dataset grows — automatically, or does it require the application or an operator to manage shard placement? A mismatch here is the most expensive to fix retroactively, since it usually means a full data migration to a different engine.
  3. Driver and ecosystem maturity for the application’s language and platform. Bluesky selected ScyllaDB for its AppViews specifically for its shard-aware Go driver — a driver that understood the cluster’s sharding topology directly, rather than treating the cluster as an opaque single endpoint — alongside its general scalability characteristics (The Pragmatic Engineer 2024). A technically capable engine with an immature or poorly maintained driver for the team’s stack is a worse operational bet than a marginally less capable engine with first-class support.
  4. Operational maturity within the organization. An engine the team already operates successfully elsewhere carries less risk than a new one, all else being close to equal — see the cost side of Polyglot persistence above.
  5. Query capability required. Confirm the engine’s query language and indexing model actually answer the access patterns identified in Query-first design, not just the primary key lookup — a document database’s secondary-index support, or a wide-column store’s support for the specific sort and filter combinations the application needs.

A database-engine migration undertaken because these criteria were not weighed correctly up front follows the mechanics in TS-45: Data migrations; this section is about avoiding that cost, not paying it.

Operational considerations

Indexing

A NoSQL database’s query engine is typically far less capable than SQL’s at answering a query for which no index exists — a missing index commonly means a query is unanswerable within the engine’s normal operating constraints, not merely slow. An index MUST be created for every field or combination of fields identified during Query-first design, before that query pattern goes into production, not added reactively once a query is already found to be slow.

Composite and sort-key indexes SHOULD be modeled around the specific combination of filter and sort a query needs, mirroring the access-pattern- first approach in Access patterns over normalization, rather than indexing every field independently and hoping the engine composes them usefully at query time.

Backup and disaster recovery

A NoSQL database’s replication (see Replication topology) protects against a single node failing; it does not protect against an application bug or an operator error that corrupts or deletes data correctly and consistently across every replica. A NoSQL database MUST have its own backup and point-in-time-recovery strategy, independent of and in addition to replication, on the same footing as a relational database’s.

Monitoring

At minimum, a production NoSQL deployment SHOULD monitor:

  • Replication lag. A stale replica directly determines how stale a read-your-writes or eventually-consistent read can be — see Consistency and replication.
  • Hot partitions or hot keys. A shard or key receiving disproportionate traffic degrades that portion of the dataset’s latency while the rest of the cluster stays healthy, and is often invisible in an aggregate cluster-wide metric.
  • Storage and compaction. Particularly for engines that use log-structured storage, where deleted or overwritten data is not reclaimed until a background compaction process runs.

Schema evolution

Even a database with no enforced schema has an implicit schema: the shape the application code expects when it reads a record back. Because the engine does not enforce that shape, a schema change is a change to the application’s read and write code, not to a database migration script, and old-shaped records already written MUST continue to be read correctly by the new code — typically by having read paths tolerate both the old and new shape until every existing record has been migrated or naturally aged out. Changing what the application writes going forward is instantaneous; changing what already exists in the database is a data migration in its own right — see TS-45: Data migrations.


References