lumo

Module 06 · Lesson 2 of 11Free

Document and key-value databases compared

Level
Intermediate
Topic
NoSQL databases and when to use them
Reading time
19 min

When you finish this page you can

  1. Choose between key-based access and document-field queries using concrete access patterns.
  2. Design record boundaries around bounded data that is read and changed together.
  3. Explain how concurrent whole-record updates lose changes and how conditional writes prevent this.

Builds on 3 lessons

Start with lesson 118 min
  1. SQL vs NoSQL: choosing a data store18 min
  2. Indexes: speeding up queries without full scans15 min
  3. Isolation levels and locking: handling concurrent writes20 min

Video lesson · coming soon

JSON doesn’t choose the database

A seller on asks for all blue shirts costing less than ₹1,000. The catalog has 20 million products, but its prototype only supports fetching one product by ID. Reading every product would turn a filter into a catalog-wide scan.

The cart service stores JSON too. Yet it usually knows exactly whose cart to fetch.

How do you choose between document and key-value storage when the records look alike?

Kartly is now at the many-services stage. We’ll focus on its catalog and cart services. Additional sizes and request rates below are illustrative assumptions for this lesson.

Check yourselfAnswer in your head first, then reveal
You store a product as JSON under the key product:p42. Can the database now efficiently find every blue shirt?Show answerHide answer
Not because it contains JSON. The database needs a supported field-query operation and a suitable index; otherwise, someone must inspect records to find matches.

Diagram not drawn yet: document-kv-access-paths.svg

Two independent panels. Key lookup: cart service sends cart:u17 to a key directory, which returns one cart value. Field query: catalog service sends category equals shirt and colour equals blue to a field index, which identifies matching product records.

The difference is the access path: provide a known key, or ask the database to find keys from fields.

A starts with a unique key. You ask it to retrieve, replace or remove the associated value. In the simplest version, field meanings belong to your application.

A exposes fields inside structured records. Its query interface can select records using those fields. Efficient selection still depends on indexes.

These are useful models, not strict product categories. DynamoDB supports nested attributes alongside primary-key access and secondary indexes. A product can support both styles.3

Build on SQL vs NoSQL: choosing a data store: choose required operations and guarantees before choosing a database name. The next step is to write those operations down.

Start with the questions you ask

Kartly’s team lists its : the precise ways each service reads or changes data.

Service Question or change Known before the request
Cart Fetch my current cart Customer ID
Cart Set one item’s quantity Customer ID and product ID
Catalog Fetch one product Product ID
Catalog List blue shirts below a price Field values, not product IDs
Catalog Edit a seller’s product description Product ID

The first and third reads look identical at the storage boundary. Both provide an identifier and expect one record.

The filtered catalog read is different. It asks storage to discover identifiers.

Which access model should Kartly use for these workloads?

Use a basic key-value interface for everything.

One key retrieves one record. Cart reads stay direct, but catalog filtering requires scans or additional lookup structures.

Require a document database for every JSON record.

Field queries are available everywhere, but unused query features do not justify migration, extra indexes or another operational dependency.

Why this choice

Start with the simplest working option: keep an existing database if it supports both workloads adequately. Different access patterns do not automatically require different database products.

For carts, key-based operations are enough under our current requirements. For catalog filtering, require field queries and suitable indexes.

This choice changes if carts need frequent cross-customer queries. It also changes if catalog traffic becomes almost entirely product-ID lookups. Choose from measured needs, not the shape of JSON.

A is not automatically a . Whether lost data is recoverable depends on its role and configured , not its access interface.

Likewise, a document database is not automatically a good search engine. This lesson’s catalog filters are exact fields and ranges. Full-text ranking is a separate requirement.

Now we need to see what each operation actually makes storage do.

Follow the key or the index

Consider this cart service layout:

Key Value Rule
cart:u17 Version and item quantities One active cart per customer
cart:u18 Version and item quantities Same format, different customer

A cart value might be:

json
{
  "version": 7,
  "items": {
    "p42": 2,
    "p91": 1
  }
}

The item map holds quantities, not authoritative prices. Checkout must obtain current prices and stock from their owners.

A key lookup follows this path:

  1. The cart service constructs cart:u17 from the authenticated customer.
  2. The storage client routes the request to the responsible .
  3. That partition locates the key using its local storage structure.
  4. Storage returns the value, or reports that the key is absent.

The routing algorithm is product-specific. For example, DynamoDB hashes its partition-key value to determine placement.3 A distributed key-value database is therefore not necessarily one large in-memory hash map.

Now consider a catalog document:

json
{
  "_id": "p42",
  "seller_id": "s8",
  "category": "shirt",
  "price_minor": 89900,
  "currency": "INR",
  "attributes": {
    "colour": "blue",
    "size": "M"
  }
}

For our filter, propose an index ordered by category, attributes.colour, then price_minor. The equality fields narrow the before the price range. The database can walk matching index entries instead of inspecting every product.

This follows the access-path reasoning from Indexes. MongoDB’s guidance recommends indexing commonly queried fields and notes that indexes add write work and consume storage.1

A possible read path is:

  1. The catalog service submits the category, colour and price conditions.
  2. The query engine selects a suitable index.
  3. Storage reads matching entries and fetches the required documents.
  4. The service returns a bounded page of products.

The index is a maintained mapping from field values to record identifiers. Its cost exists even when nobody is running that query.

Could the cart-style store maintain its own blue-shirts lookup? Yes. But inserting a product now means updating both the product and that lookup. A crash between them can hide a product or leave an outdated entry.

Building that mapping yourself means accepting responsibility for index maintenance. Before doing so, compare its cost with a database that already owns the operation.

One cart needs one boundary

Suppose Kartly stores every cart item under its own key. Fetching the cart now needs a way to discover its item keys. Editing several items also crosses multiple records.

The simplest alternative is one bounded cart value. This makes one customer’s current selection a useful : a group of data treated as one read or update boundary.

That boundary should follow behavior, not merely ownership. A product belongs to a seller, but every seller product inside one seller document creates a growing record.

What should Kartly keep together inside one record?

Store every cart item as a separate record.

Each item can change independently, but listing a cart needs another access path and whole-cart operations may need coordination.

Embed seller profiles, stock and all reviews inside each product.

A product read contains everything, but seller edits must update many copies and reviews can grow without a useful bound.

Why this choice

For this scenario, cap a cart at 100 distinct products. Keep its quantities together. Embed a product’s descriptive attributes, but reference independently managed data.

MongoDB’s modeling guidance favors embedding data queried together. It recommends references for unbounded growth and independently written data.1

The cart choice changes when carts become large shared documents with frequent independent edits. Separate item records may then reduce contention, provided we also design cart listing and multi-item operations.

Here is the service boundary, independent of the chosen vendor:

Diagram not drawn yet: document-kv-service-boundaries.svg

Two independent service paths. The customer app sends a cart request to the cart service, which reads and conditionally replaces a versioned cart record. The seller app sends a catalog request to the catalog service, which reads and updates product documents and their field index.

Each service owns validation and business rules. Storage owns record lookup, index maintenance where supported, and the required atomic write operation.
Component Responsibility Talks to State it keeps Failure behavior
Client apps Display data and submit intent Owning service Unsaved edits Preserve edits and show retry state
Cart service Validate quantities and versions Cart storage Request-local snapshot Restart; client reloads saved cart
Cart storage Read and conditionally replace carts Cart service Versioned carts Return an error; don’t invent an empty cart
Catalog service Validate product fields and filters Catalog storage Request-local data Restart; unsaved seller edits remain client-side
Catalog storage Store documents and maintain indexes Catalog service Products and indexes Report unavailable; recover through the store’s recovery process

A catalog write validates the seller’s edit, updates the document and lets storage maintain the relevant indexes. A cart write reads the record, calculates a replacement, then submits that replacement conditionally.

That final word matters. Keeping data together does not prevent two writers from overwriting each other.

A replacement can erase an edit

Asha opens her cart on her phone and laptop. Both read version 7.

The phone adds a shirt. The laptop adds a mug. Each sends a complete replacement based on the old snapshot.

Order Phone Laptop
1 Reads version 7 Reads version 7
2 Writes shirt addition
3 Writes mug addition from old snapshot

The shirt disappears. Each individual replacement can be atomic, yet the combined read-modify-write sequence still loses an update.

Kartly needs : replace the record only if its current version equals the version you read. The check and replacement must happen in one atomic storage operation.

A cart write now proceeds as follows:

  1. Read version 7 and its quantities.
  2. Apply the requested edit locally.
  3. Request replacement only if the stored version is still 7.
  4. On success, save version 8.
  5. On conflict, reload and decide whether the edit can be applied again.

This is the conditional-update reasoning from Isolation levels and locking, applied to a whole value.

Redis offers one implementation using WATCH: if a watched key changes before EXEC, the transaction aborts instead of applying its queued writes.2

The following examples implement the required semantics locally. They belong inside the cart storage component in the diagram. They are teaching models, not persistent databases.

python
from threading import Lock

class CartStore:
    def __init__(self) -> None:
        self._rows: dict[str, tuple[int, dict[str, int]]] = {}
        self._lock = Lock()

    def read(self, key: str) -> tuple[int, dict[str, int]]:
        with self._lock:
            version, items = self._rows.get(key, (0, {}))
            return version, items.copy()

    def replace(
        self,
        key: str,
        expected: int,
        items: dict[str, int],
    ) -> int | None:
        with self._lock:
            version, _ = self._rows.get(key, (0, {}))
            if version != expected:
                return None
            next_version = version + 1
            self._rows[key] = (next_version, items.copy())
            return next_version
ts
type Items = Record<string, number>;
type Cart = { version: number; items: Items };

class CartStore {
  private readonly rows = new Map<string, Cart>();

  read(key: string): Cart {
    const row = this.rows.get(key) ?? { version: 0, items: {} };
    return { version: row.version, items: { ...row.items } };
  }

  replace(
    key: string,
    expected: number,
    items: Items,
  ): number | null {
    const current = this.rows.get(key);
    const version = current?.version ?? 0;
    if (version !== expected) return null;
    const nextVersion = version + 1;
    this.rows.set(key, {
      version: nextVersion,
      items: { ...items },
    });
    return nextVersion;
  }
}

Python’s lock protects the check and replacement together. The TypeScript method performs both synchronously in one JavaScript execution context, without an asynchronous gap.

Copies prevent callers from changing stored quantities outside replace. Neither example coordinates separate processes or survives a restart.

In production, require equivalent behavior from storage. A service-side lock on one server cannot protect writes arriving through another server.

also do not solve every retry. If the write succeeds but its reply disappears, a timeout leaves the outcome unknown. Reload before deciding what to do. Repeating “set quantity to 3” differs from repeating “add 1”.

The record model tells you where an atomic change could fit. You must still choose the operation and its retry behavior.

Size the value before scaling

For this lesson’s capacity example, assume a typical serialized cart occupies 4 KB. Assume 10,000 cart replacements arrive each second.

Calculate replacement traffic

10,000 writes/s × 4 KB/write = 40 MB/s

Whole-value replacement sends about 40 MB each second, before protocol, replication and storage overhead.

Increase the record, not traffic

10,000 writes/s × 40 KB/write = 400 MB/s

If cart records grow tenfold, the same request rate sends ten times as much application payload.

These are decimal units and scenario estimates, not database benchmarks. A field-update API can reduce request payload. Its physical storage work depends on the engine; do not assume it rewrites only those bytes.

Bound record size before adding infrastructure. Then measure whether bytes, conflicts or storage latency dominate.

During a festival sale, a popular product can receive concentrated catalog reads. Choosing document storage does not distribute one product’s popularity. Changing the cart’s key format does not fix that catalog bottleneck.

Monitor the behavior users experience:

Signal Likely question Immediate response
Cart conflict rate rises Are several clients editing the same cart? Bound retries; ask the client to refresh if conflicts continue
Cart reads time out Is storage unavailable or overloaded? Preserve the visible cart; report temporary failure
Catalog records examined rise Did a filter lose its useful index? Inspect the before adding capacity
Invalid record errors rise Did a writer change the format? Stop the faulty writer and repair affected records

Never translate a storage timeout into “cart not found”. That would show an empty basket even though the customer’s choices may still be saved.

Common mistakes

Choosing by JSON alone. JSON describes a representation. It does not promise indexed filtering, conditional writes or durability.

Calling flexible records schema-free. Kartly still needs rules for quantities, currency and attribute types. Validate writes and plan how readers handle older records.

Adding every possible index. Start with actual queries. Otherwise, seller updates pay to maintain access paths nobody uses.

Keeping authoritative stock inside the cart. A cart expresses customer intent. It does not reserve inventory or prove availability.

Assuming “transaction” means identical behavior everywhere. Redis transactions do not roll back successful commands when another queued command fails during execution.2 Read the exact guarantees rather than borrowing expectations from another database.

Going deeper

When the record boundary needs to change

A version condition creates conflicts between unrelated edits inside the same record. If many people share a cart, consider storage-supported per-item updates or separate item records.

With separate records, a cart-wide discount or clear operation now needs explicit semantics across items. Reducing write contention can increase coordination elsewhere.

Our code has no deletion, expiry or restart recovery. Reusing a version after deleting and recreating a cart could make an old condition appear valid. A production design needs a generation identifier or another mechanism that prevents stale versions from matching a recreated record.

Also distinguish conditional writes from read freshness and durability. Passing a version check does not, by itself, describe replica reads or survival after failover. Verify those guarantees separately for the selected deployment.

Where this shows up

The next lesson, Columnar and wide-column databases compared, asks what changes when reads group data by columns or partitioned rows.

Check your understanding

Check yourselfAnswer in your head first, then reveal
Kartly stores every product as a JSON value addressed by product ID. A new filter asks for shirts by colour. What is missing?Show answerHide answer
A supported access path from field values to product IDs, usually an index. JSON alone does not avoid scanning records, and an application-maintained lookup adds consistency work.
Check yourselfAnswer in your head first, then reveal
Two clients read cart version 12 and submit different replacements requiring version 12. What must storage do?Show answerHide answer
Accept at most one replacement and advance the version. The other must conflict because the check and replacement happen atomically; two separate check and write calls would not provide that protection.
Check yourselfAnswer in your head first, then reveal
A seller has an unlimited number of reviews. Should Kartly embed all reviews in the product document to save one read?Show answerHide answer
No. Unbounded growth makes the product an increasingly expensive record to fetch and update; keep reviews separately and fetch a bounded page.
Check yourselfAnswer in your head first, then reveal
The cart read times out. Should the service return an empty cart and let the customer start again?Show answerHide answer
No. A timeout does not establish absence, and a later replacement could erase saved items. Preserve the visible cart and report a temporary failure.

Summary

  1. Choose key-value or document capabilities by required access paths, not by whether records contain JSON.
  2. Known-key reads retrieve identified records; field queries need suitable indexes to avoid broad scans.
  3. Keep bounded data together when it is read and changed together; separate independently growing data.
  4. Atomic replacement does not protect a read-modify-write sequence. Use an atomic version condition to prevent lost updates.
  5. Measure record size, conflicts and query work. Verify durability and retry behavior separately from the data model.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.