← Back to the library
BackendHow it works · Applied · 15 min

Database types and how to choose one

Relational, document, key-value, wide-column, graph, search, OLAP, vector: which one does this access pattern actually need?

Each database family is a bet on one shape of data and one set of queries, and it pays for that with weakness elsewhere. Start with Postgres, then add a specialised store only when a measured access pattern outgrows it, and keep one system as the source of truth.

THE MENTAL MODEL

A database is a data structure on disk plus a promise about concurrency. Relational engines store normalised rows behind B-tree indexes and a planner, so you can ask questions you did not anticipate, with joins, constraints and ACID transactions guarding the invariants. Every other family gives up some of that generality to win at one thing: documents keep an aggregate together, key-value and wide-column stores fix the query to a partition key so they can spread across machines, graphs make traversal cheap, search engines invert text into term-to-document lists, columnar OLAP engines read only the columns an aggregate touches, and vector indexes trade exactness for fast nearest-neighbour lookups. The rule that falls out of this is design-by-access-pattern: write down the queries, their latency and volume, and the consistency they need, then pick the least exotic store that answers them. For most products that is one Postgres database for a long time, with Redis for ephemeral state, and specialised stores fed from it by change data capture when a specific read path demands it.

HOW IT FITS TOGETHER

Database families at a glanceDefaults, not limits: most engines can be pushed beyond their column, but you pay in operational effort and surprise.
Data modelQuery strengthScales byDefault consistencyPick when
Relational (Postgres, MySQL)Typed rows in tables, foreign keys, constraintsAd hoc SQL, joins, aggregates, multi-row transactionsBigger node, read replicas; sharding via Citus or VitessACID on the primary; replicas are async by defaultDefault for business data and anything with invariants
Document (MongoDB)JSON-like documents, nested arrays, flexible schemaFetch or update a whole aggregate by key or indexReplica sets; sharding on a chosen shard keyPrimary reads; majority write concern by defaultAggregates are read whole and their shape varies
Key-value (Redis, Valkey)Keys to strings, hashes, lists, sets, sorted setsSub-millisecond get/set, counters, leaderboards, TTLsCluster mode: 16384 hash slots across primariesAsync replication; failover can drop acked writesCaches, sessions, rate limits, locks, ephemeral state
Key-value (DynamoDB)Items under partition key plus optional sort keyKey lookups and sort-key ranges at any scaleHash partitioning, managed and automaticEventual reads by default; strong reads opt-inKnown access patterns, huge scale, serverless ops
Wide-column (Cassandra)Partitions of rows clustered by a sort orderHigh write throughput, reads by partition keyConsistent-hash ring, leaderless, multi-DCTunable per query: ONE, QUORUM, ALLWrite-heavy, always-on, multi-region time-ordered data
Graph (Neo4j)Nodes and typed relationships with propertiesMulti-hop traversals and path queriesMostly scale-up; clustered primaries plus replicasACID transactionsThe question is about connections, many hops deep
Time-series (Timescale, Influx)Timestamped points tagged by seriesWindowed aggregates, downsampling, retentionTime-based chunks, compression, tieringEngine-specific; TimescaleDB inherits PostgresMetrics, IoT and append-mostly event streams
Search (Elasticsearch)Documents in an inverted index of analysed termsFull-text relevance, fuzzy match, facetsIndex shards with replicas across nodesNear real-time: searchable after a refreshUsers type words and expect ranked results
Columnar OLAP (ClickHouse)Columns stored and compressed separatelyScans and aggregates over billions of rowsShards and parallel execution; DuckDB is in-processBatch inserts; updates and deletes are costlyAnalytics and dashboards off the OLTP primary
Vector (pgvector, dedicated)Embeddings as fixed-length float arraysApproximate nearest neighbour by distanceInherits host DB; dedicated stores shard indexesSame as host; ANN results are approximateSemantic search, RAG retrieval, recommendations
Embedded (SQLite, DuckDB)A library and a single file inside your processZero-latency local SQLDoes not; one machine, one writer at a timeACID; serialised writesMobile, desktop, edge, tests, local analytics
Choosing a store from access patternsEvery step only adds a store when a named query fails on the previous one; the source of truth rarely moves.
  1. List the access patternsEach read and write, its filter keys, ordering, volume, latency target and freshness requirement.
  2. Default to PostgresModel entities relationally with constraints. Most patterns are an index away from fast enough.
  3. Measure what failsEXPLAIN ANALYZE, p99 latency and load per pattern. Fix indexes, N+1s and pooling before blaming the model.
  4. Ephemeral or hot? RedisSessions, rate limits, locks, counters and cache entries that can be rebuilt or lost.
  5. Text relevance? SearchFeed Elasticsearch or OpenSearch from the primary; try Postgres full-text search first.
  6. Big aggregates? OLAPStream changes into ClickHouse, BigQuery or DuckDB so dashboards stop scanning the primary.
  7. Scale beyond one node?Key-shaped, partitionable patterns go to DynamoDB or Cassandra; relational ones to Citus or a distributed SQL engine.
  8. Sync with CDC or outboxDerived stores are fed from the source of truth through change data capture or an outbox, never by dual writes.

KEY TERMS

Access-pattern-first design
Write the queries before the schema. Relational modelling tolerates unknown future queries; DynamoDB, Cassandra and Redis do not, because their keys are the only fast path. If you cannot list the patterns yet, that alone is an argument for a relational store.
Normalisation vs aggregates
Relational designs store each fact once and join at read time. Document and key-value designs duplicate data so one read returns the whole aggregate. Duplication is fast to read and expensive to keep consistent when the shared fact changes.
Partition key
In DynamoDB, Cassandra, Redis Cluster and sharded MongoDB, the partition or shard key decides which node owns a record. Queries that include it hit one partition; queries that do not fan out or need a secondary index. A low-cardinality or time-only key creates hot partitions.
OLTP vs OLAP
OLTP touches few rows per query with many concurrent writers, so row storage and B-trees win. OLAP scans many rows but few columns, so columnar storage, compression and vectorised execution win. Running heavy analytics on the OLTP primary steals its I/O and buffer cache.
Approximate nearest neighbour
Vector indexes such as HNSW and IVFFlat find close embeddings without comparing every row, at the cost of recall. In pgvector, a WHERE filter is applied after the index scan, so a selective filter can return fewer rows than LIMIT unless iterative scans (0.8.0+) are enabled.
Polyglot persistence tax
Every extra store adds backups, upgrades, monitoring, access control, a sync pipeline and a new way to be inconsistent. Postgres extensions (pgvector, TimescaleDB, full-text search, JSONB) often cover a pattern well enough to defer that tax.
Source of truth
Exactly one system owns each fact; caches, search indexes and analytics copies are derived from it and can be rebuilt. Writing to two stores from application code without a transaction or outbox guarantees they will eventually disagree.

IN YOUR STACK

SQL · PostgreSQL Server

CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers (id),
  status      text   NOT NULL CHECK (status IN ('pending', 'paid', 'shipped')),
  total_cents bigint NOT NULL CHECK (total_cents >= 0),
  created_at  timestamptz NOT NULL DEFAULT now()
);
-- Matches the access pattern: one customer, newest first.
CREATE INDEX orders_customer_recent ON orders (customer_id, created_at DESC);

-- Latest 20 orders with item counts, keyset-paginated.
SELECT o.id, o.status, o.total_cents, o.created_at, count(i.id) AS items
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
WHERE o.customer_id = $1
  AND o.created_at < $2          -- cursor: created_at of the last row seen
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT 20;
  • Constraints and foreign keys enforce invariants in the database, so every writer, including scripts and migrations, obeys them.
  • The composite index serves the filter and the sort together; without it this becomes a sort over every order the customer has.
  • The same schema answers questions nobody planned for (revenue by status, customers with no orders) without a redesign.
PostgreSQL documentation (opens in new tab)

TypeScript · MongoDB Node.js driver Server

import { MongoClient, ObjectId } from "mongodb";

type Order = {
  _id?: ObjectId;
  customerId: string;
  status: "pending" | "paid" | "shipped";
  totalCents: number;
  createdAt: Date;
  items: { sku: string; qty: number; priceCents: number }[]; // embedded: read with the order
};

const client = new MongoClient(process.env.MONGODB_URI!);
const orders = client.db("shop").collection<Order>("orders");
await orders.createIndex({ customerId: 1, createdAt: -1 });

export async function recentOrders(customerId: string) {
  return orders.find({ customerId }).sort({ createdAt: -1 }).limit(20).toArray();
}

export async function placeOrder(order: Omit<Order, "_id">) {
  return orders.insertOne(order); // single-document writes are atomic
}
  • Embedding line items fits because they are always read and written with their order; data accessed together is stored together.
  • Customer details are referenced, not embedded: copying them into every order means updating many documents when an email changes.
  • Writes spanning several documents need a multi-document transaction; if most writes do, the data is probably relational.
MongoDB Node.js driver documentation (opens in new tab)

TypeScript · DynamoDB (AWS SDK v3) Server

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const Table = "shop";

// PK groups a customer's items in one partition; SK sorts them by type and time.
export async function placeOrder(customerId: string, orderId: string, totalCents: number) {
  const createdAt = new Date().toISOString();
  await db.send(new PutCommand({
    TableName: Table,
    Item: { PK: `CUSTOMER#${customerId}`, SK: `ORDER#${createdAt}#${orderId}`, orderId, totalCents, status: "paid" },
  }));
}

export async function recentOrders(customerId: string) {
  const { Items } = await db.send(new QueryCommand({
    TableName: Table,
    KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
    ExpressionAttributeValues: { ":pk": `CUSTOMER#${customerId}`, ":prefix": "ORDER#" },
    ScanIndexForward: false, // newest first
    Limit: 20,
  }));
  return Items ?? [];
}
  • The key design is the query: this table answers orders-by-customer in one partition read and nothing else until you add a GSI.
  • Fetching an order by its id needs a GSI keyed on orderId; GSI reads are always eventually consistent.
  • Each partition serves at most 3,000 read units and 1,000 write units per second, so a single very hot customer key is a ceiling.
DynamoDB (AWS SDK v3) documentation (opens in new tab)

Redis commands · Redis / Valkey Server

# Recent-orders index per customer: sorted set scored by epoch milliseconds.
ZADD customer:123:orders 1791710400000 o_789
# Keep only the newest 100 entries.
ZREMRANGEBYRANK customer:123:orders 0 -101
EXPIRE customer:123:orders 86400

# Order summary as a hash.
HSET order:o_789 status paid total_cents 4200 customer_id 123
EXPIRE order:o_789 86400

# Read path: newest 20 ids, then fetch each summary (pipeline these).
ZRANGE customer:123:orders 0 19 REV
HGETALL order:o_789
  • Treat this as a derived, rebuildable view in front of the real orders table; with async replication a failover can lose acknowledged writes.
  • ZRANGE with REV (Redis 6.2+) replaces ZREVRANGE; the same commands work on Valkey.
  • There is no secondary indexing or ad hoc querying: every question you want answered needs its own key structure maintained on write.
Redis / Valkey documentation (opens in new tab)

Cypher · Neo4j Server

// The plain access pattern works, but is not why you would pick a graph.
MATCH (c:Customer {id: $customerId})-[:PLACED]->(o:Order)
RETURN o.id, o.status, o.totalCents, o.createdAt
ORDER BY o.createdAt DESC
LIMIT 20;

// Where graphs earn their keep: products bought by customers who bought what I bought.
MATCH (c:Customer {id: $customerId})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
      <-[:CONTAINS]-(:Order)<-[:PLACED]-(other:Customer)
      -[:PLACED]->(:Order)-[:CONTAINS]->(rec:Product)
WHERE other <> c
  AND NOT EXISTS { (c)-[:PLACED]->(:Order)-[:CONTAINS]->(rec) }
RETURN rec.sku, count(DISTINCT other) AS buyers
ORDER BY buyers DESC
LIMIT 10;
  • Traversal cost depends on the edges actually visited, not on table sizes, which is what makes five-hop questions practical.
  • The same recommendation in SQL is a chain of self-joins whose plan degrades quickly as hops are added.
  • Create an index or constraint on :Customer(id) so the starting node is a lookup, not a label scan.
Neo4j documentation (opens in new tab)

SQL · ClickHouse Server

CREATE TABLE order_events
(
    order_id    String,
    customer_id UInt64,
    status      LowCardinality(String),
    total_cents Int64,
    created_at  DateTime64(3)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (customer_id, created_at);

-- Monthly revenue for one customer over a year...
SELECT toStartOfMonth(created_at) AS month, count() AS orders, sum(total_cents) / 100 AS revenue
FROM order_events
WHERE customer_id = 123 AND created_at >= now() - INTERVAL 12 MONTH
GROUP BY month ORDER BY month;

-- ...and the query it exists for: every customer, every order, in seconds.
SELECT status, quantile(0.95)(total_cents) AS p95_cents, count() AS n
FROM order_events GROUP BY status;
  • ORDER BY defines a sparse primary index over granules of 8,192 rows; filters on its prefix skip most of the data.
  • Only the referenced columns are read and they compress well, which is why full-table aggregates stay fast.
  • Insert in batches fed from the OLTP database or an event stream; frequent single-row inserts and row updates fight the engine.
ClickHouse documentation (opens in new tab)

SQL · PostgreSQL + pgvector Server

CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE products ADD COLUMN embedding vector(1536);
CREATE INDEX products_embedding_hnsw ON products USING hnsw (embedding vector_cosine_ops);

-- Products semantically similar to the customer's last order, in stock only.
SET hnsw.iterative_scan = relaxed_order;  -- keep scanning when the filter discards rows
SELECT p.sku, p.name, p.embedding <=> $1 AS cosine_distance
FROM products p
WHERE p.in_stock
  AND p.id NOT IN (SELECT product_id FROM order_items WHERE order_id = $2)
ORDER BY p.embedding <=> $1
LIMIT 10;
  • The operator in ORDER BY must match the index opclass: <=> with vector_cosine_ops, <-> with vector_l2_ops, <#> with vector_ip_ops.
  • HNSW indexes vector columns up to 2,000 dimensions; halfvec allows up to 4,000 and halves memory.
  • Keeping embeddings next to the relational data lets one query combine similarity, joins and filters, which a separate vector store would need two round trips for.
PostgreSQL + pgvector documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Relational: PostgreSQL or MySQL

Use when Business entities with relationships and invariants, transactions across rows, reporting you cannot fully predict, and teams that want one well-understood system.

Avoid when A single write path needs more throughput than one primary can give and the data partitions cleanly by key, or the data is mostly blobs and time-series at very high ingest.

Document: MongoDB

Use when Each read and write naturally targets one self-contained aggregate whose shape varies (catalog items, CMS content, user-defined forms).

Avoid when Many-to-many relationships and cross-entity invariants dominate; you will rebuild joins and transactions in application code.

Key-value: Redis or Valkey

Use when Ephemeral or rebuildable state needing sub-millisecond access: caches, sessions, rate limits, distributed locks with fencing, leaderboards, queues of small jobs.

Avoid when It would be the only copy of data you cannot afford to lose, or the dataset will not fit comfortably in memory.

Key-value: DynamoDB

Use when Access patterns are known and key-shaped, scale or spikiness is large, and you want zero server operations on AWS.

Avoid when Requirements are still moving, you need ad hoc queries or aggregates, or most reads need strong consistency through secondary indexes.

Wide-column: Cassandra/ScyllaDB

Use when Very high write volume, multi-datacenter active-active, and reads that always know the partition key (events per device, messages per channel).

Avoid when You need joins, ad hoc filters, read-modify-write transactions, or a small team cannot operate a cluster.

Graph: Neo4j

Use when The core questions are multi-hop: fraud rings, recommendations, permissions inheritance, dependency and network analysis.

Avoid when Relationships are one or two hops; Postgres joins or a recursive CTE answer it with one less system.

Time-series: TimescaleDB/InfluxDB

Use when Append-mostly timestamped data with retention, downsampling and windowed aggregates, such as metrics and sensor readings.

Avoid when Volume is modest; a Postgres table partitioned by time with a BRIN or B-tree index is enough.

Search: Elasticsearch/OpenSearch

Use when Users search by free text and expect relevance ranking, typo tolerance, highlighting and faceted filters over large corpora.

Avoid when Filtering is structured, or Postgres full-text search with GIN indexes is good enough; never as the source of truth.

Columnar OLAP: ClickHouse etc.

Use when Dashboards, product analytics and reporting scan large histories; DuckDB when it fits on one machine or runs inside a notebook or job.

Avoid when The workload is many small transactional reads and updates by key.

Vector: pgvector or dedicated

Use when Semantic search, RAG retrieval or recommendations over embeddings; pgvector while the corpus fits one Postgres, dedicated stores for very large or multi-tenant indexes.

Avoid when Exact keyword or structured matching is what users need, or you have not measured whether lexical search already works.

Embedded: SQLite

Use when Data lives with one app or device: mobile and desktop apps, edge functions with replicated SQLite, CLIs, tests and single-server sites.

Avoid when Many processes on different machines write concurrently, or the data must be shared over a network.

WHERE IT BITES

  • Choosing a NoSQL store for scale you do not have, then rebuilding joins, constraints and transactions in application code.
  • Designing a DynamoDB or Cassandra table before listing access patterns, then discovering the new screen needs a scan.
  • Dual-writing to the database and a search index or cache from the request handler; a crash between the two leaves them permanently diverged.
  • Using Redis as the only copy of durable data while it runs with async replication and default persistence settings.
  • Running analytics queries on the OLTP primary until a dashboard refresh becomes the incident.
  • Adding a dedicated vector database before checking whether pgvector, or plain full-text search, already meets recall and latency targets.

CLOSE THE AI. EXPLAIN THIS.

Your app on Postgres needs typo-tolerant product search, a revenue dashboard over three years of orders, and per-user rate limiting. For each, would you stay in Postgres or add a store, which one, and how does data reach it?

SOURCES

Explainer reviewed