Guide contents

Lesson 28 of 30

SQL vs NoSQL

SQL databases store data in tables with a fixed schema, query with SQL, and typically provide ACID transactions. NoSQL covers databases that use other data models and usually trade some relational features for scale or flexibility. See also SQL vs NoSQL.

Example 1: comparison table

Relational (SQL) NoSQL
Data model Tables, rows, foreign keys Key-value, document, wide-column, graph, time-series
Schema Defined up front (schema-on-write) Flexible, often applied on read (schema-on-read)
Query language SQL, joins, aggregates Store-specific APIs; limited joins
Transactions Multi-row ACID Often single-key or single-document; some support multi-document ACID (MongoDB 4.0+)
Scaling Vertical first; replicas; sharding is manual Designed for horizontal scale-out
Consistency Strong by default Often tunable, eventual by default in some
Examples PostgreSQL, MySQL, SQL Server, Oracle Redis, MongoDB, Cassandra, Neo4j, InfluxDB

Example 2: NoSQL families and when to use each

Family Model Use when Examples
Key-value Key maps to an opaque value Sessions, caches, counters, feature flags Redis, DynamoDB, Memcached
Document JSON-like documents with nested fields Varied records read as a unit: catalogs, profiles, content MongoDB, Couchbase
Wide-column Rows keyed by partition key with sparse column families Huge write volume, known access patterns: messaging, IoT, event logs Cassandra, HBase, ScyllaDB
Graph Nodes and edges Relationship traversals: social graphs, fraud rings, recommendations Neo4j, Amazon Neptune
Time-series Timestamped points, downsampling, retention Metrics, sensors, monitoring InfluxDB, TimescaleDB, Prometheus

Example 3: ACID vs BASE

ACID (relational) BASE (many NoSQL stores)
Atomic: all or nothing Basically Available: answers even during partial failure
Consistent: constraints always hold Soft state: state may change without input as replicas converge
Isolated: concurrent transactions do not interfere Eventually consistent: replicas converge if writes stop
Durable: committed data survives crashes

Details of ACID are in transactions and ACID; the consistency trade-off is in replication and CAP.

Example 4: schema-on-write vs schema-on-read

Schema-on-write Schema-on-read
Validation At insert; bad data is rejected At query time; the reader interprets the shape
Change cost ALTER TABLE and migration New fields appear without migration
Data quality Enforced by types and constraints Application must handle missing or mixed shapes
Example PostgreSQL column with NOT NULL and CHECK JSON documents in MongoDB, files in a data lake

PostgreSQL can combine both: typed columns for stable fields and a jsonb column for variable ones.

Example 5: JSONB in PostgreSQL

jsonb stores parsed JSON in a binary format that supports indexing.

SQL
CREATE TABLE product_docs (
    id  serial PRIMARY KEY,
    doc jsonb NOT NULL
);
INSERT INTO product_docs (doc) VALUES
  ('{"name": "Laptop",  "price": 65000, "tags": ["electronics", "work"], "specs": {"ram_gb": 16}}'),
  ('{"name": "Mouse",   "price": 800,   "tags": ["electronics", "accessory"]}'),
  ('{"name": "Desk",    "price": 12000, "tags": ["furniture", "work"], "specs": {"material": "oak"}}');

SELECT id,
       doc ->> 'name'               AS name,
       (doc ->> 'price')::int       AS price,
       doc -> 'specs' ->> 'ram_gb'  AS ram_gb
FROM product_docs
ORDER BY id;
Output
 id |  name  | price | ram_gb 
----+--------+-------+--------
  1 | Laptop | 65000 | 16
  2 | Mouse  |   800 | 
  3 | Desk   | 12000 | 
(3 rows)

Operators: -> returns jsonb, ->> returns text, #> and #>> follow a path. A missing key gives NULL, which is how the Mouse row has no ram_gb.

Example 6: containment and key-existence queries

SQL
SELECT doc ->> 'name' AS name
FROM product_docs
WHERE doc @> '{"tags": ["work"]}'
ORDER BY id;
Output
  name  
--------
 Laptop
 Desk
(2 rows)
SQL
SELECT doc ->> 'name' AS name
FROM product_docs
WHERE doc ? 'specs'
ORDER BY id;
Output
  name  
--------
 Laptop
 Desk
(2 rows)
Operator Meaning
@> Left contains right
? Top-level key exists
?|, ?& Any / all of the keys exist
jsonb_path_exists SQL/JSON path predicate

Example 7: GIN index on JSONB

A GIN (generalized inverted) index maps each key and value to the rows containing it, so @> and ? avoid scanning the table. The demo uses a larger table.

SQL
CREATE TABLE events_json (id int, doc jsonb);
INSERT INTO events_json
SELECT g, jsonb_build_object('user', g % 1000, 'type', CASE WHEN g % 50 = 0 THEN 'purchase' ELSE 'view' END)
FROM generate_series(1, 50000) g;
CREATE INDEX events_json_gin ON events_json USING gin (doc);
ANALYZE events_json;

EXPLAIN (COSTS OFF)
SELECT count(*) FROM events_json WHERE doc @> '{"type": "purchase"}';
Output
                            QUERY PLAN                            
------------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on events_json
         Recheck Cond: (doc @> '{"type": "purchase"}'::jsonb)
         ->  Bitmap Index Scan on events_json_gin
               Index Cond: (doc @> '{"type": "purchase"}'::jsonb)
(5 rows)
SQL
SELECT count(*) FROM events_json WHERE doc @> '{"type": "purchase"}';
Output
 count 
-------
  1000
(1 row)

For a single frequently queried field, a B-tree expression index is smaller and supports ranges and sorting:

SQL
CREATE INDEX events_json_user ON events_json (((doc ->> 'user')::int));
ANALYZE events_json;
EXPLAIN (COSTS OFF)
SELECT count(*) FROM events_json WHERE (doc ->> 'user')::int = 7;
Output
                            QUERY PLAN                             
-------------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on events_json
         Recheck Cond: (((doc ->> 'user'::text))::integer = 7)
         ->  Bitmap Index Scan on events_json_user
               Index Cond: (((doc ->> 'user'::text))::integer = 7)
(5 rows)

Example 8: polyglot persistence

Systems often use several stores, each for the data it handles best.

flowchart LR
    APP["Application"] --> PG[("PostgreSQL: orders, users")]
    APP --> RD[("Redis: cache, sessions")]
    APP --> ES[("Elasticsearch: search")]
    APP --> KF["Kafka: event log"]
    KF --> DW[("Warehouse: analytics")]
    PG -. "CDC" .-> KF

The cost is operational: more systems to run and keep consistent. Change data capture (CDC) or an outbox table keeps derived stores in sync with the source of truth.

Example 9: OLTP, OLAP, HTAP, and columnar storage

OLTP OLAP HTAP
Workload Many short reads and writes of few rows Few long scans and aggregates over many rows Both on the same system
Storage Row-oriented Column-oriented Row and column copies
Optimised for Latency, concurrency, transactions Throughput on scans Fresh analytics without ETL
Examples PostgreSQL, MySQL Snowflake, BigQuery, Redshift, ClickHouse TiDB, SingleStore, SQL Server, Oracle in-memory
flowchart LR
    O[("OLTP database")] -->|"ETL / CDC"| W[("Data warehouse (columnar)")]
    W --> B["BI dashboards"]

Columnar storage keeps each column together. An aggregate such as SUM(amount) reads only that column, and similar values compress well. Row storage reads whole rows, which suits fetching one full record.

Example 10: caching layer and connection pooling

Pattern Flow Risk
Cache-aside App reads cache; on a miss reads the database and fills the cache Stale data after writes; invalidate or use a TTL
Write-through Writes go to cache and database together Extra write latency
Write-behind Writes go to cache, flushed to database later Data loss if the cache fails

Cache stampede (many misses for the same key at once) is reduced with locks, request coalescing, or staggered TTLs. Redis and Memcached are common caches. Connection pooling (PgBouncer, HikariCP) limits database connections; see replication and CAP.

Interview decision guide

Requirement Choose
Transactions across several rows or tables, joins, reporting Relational
Flexible documents read and written as a unit Document store, or PostgreSQL with jsonb
Sub-millisecond lookups by key, caching Key-value (Redis)
Massive write throughput with known query patterns and multi-region Wide-column (Cassandra)
Deep relationship traversals Graph
Metrics with retention and downsampling Time-series
Heavy analytics over history Columnar warehouse
Unsure Relational; it covers most needs and JSONB handles flexible fields

Interview points:

  • State the access patterns, consistency needs, and scale before naming a database.
  • NoSQL does not mean no transactions or no schema; the schema moves into the application.
  • Relational databases scale reads with replicas and writes with partitioning and sharding, see partitioning and sharding.
  • Denormalisation in NoSQL trades write cost and duplication for read speed.

Summary

  • Relational databases give schemas, joins, and ACID transactions; NoSQL families (key-value, document, wide-column, graph, time-series) fit specific access patterns.
  • ACID favours correctness; BASE favours availability and scale with eventual consistency.
  • PostgreSQL jsonb with a GIN index handles flexible documents inside a relational database.
  • OLTP uses row storage for transactions; OLAP uses columnar storage for analytics; polyglot systems combine stores.
  • Start from access patterns and consistency needs, and default to relational when unsure.