Storage

Choose storage by access pattern, durability, and interface, from memory and disks to databases and object stores.

Storage preserves data so another operation can use it later. Choosing it means deciding how data is addressed, which questions can be answered efficiently, and what must survive before the application says “saved.” A session lookup, a bank transfer, and a video upload have different answers to those questions.

Prerequisites: basic SQL, processes, and files. The complete example download runs with Python 3.11+ and SQLite JSON support: unpack it and run python3 storage.py. It uses temporary files and no external services. Optional Neo4j, AWS CLI, and Git LFS exercises require the tools and access described below.

Media, interfaces, and databases are different choices

RAM, HDD, SSD, NVMe, object storage, and relational databases are not competing items in one category.

LayerWhat the choice determines
MediumRAM is ordinarily volatile working memory; HDDs use magnetic disks; SSDs use persistent flash.
Device interfaceNVMe is a storage protocol/interface family, commonly used to access SSDs over PCIe. It is not a third medium alongside HDD and SSD.
Storage interfaceBlocks, files, or objects determine how callers address and change bytes.
Database model and engineQueries, constraints, transactions, and physical organization determine how applications manage records.
Artifact toolingVersioning and distributing large assets solves a separate development workflow problem.

Block storage exposes addressable blocks. A filesystem builds named files and directories over storage and supports operations such as opening a file and writing at an offset. Object storage exposes keys, content, and metadata through an API; a key resembling a path need not identify a filesystem directory.

These choices combine. A relational database can store its files on a filesystem backed by an SSD-based AWS EBS volume. It may serve many reads from RAM. On EC2 Nitro instances, EBS volumes appear as NVMe devices even though EBS is network-attached. A device named /dev/nvme0n1 therefore establishes neither physical locality nor the data's lifetime.

Start with the questions and invariants

A useful storage decision starts with access patterns: lookup by what key, over what range, returning how many records? Then add invariants: which changes must commit together, which relationships must remain valid, and how stale may a result be?

ModelUseful access patternDesign consequence
RelationalJoin customers to orders; update related records together.Define keys, constraints, and transaction boundaries.
Key-valueRetrieve a session using its exact session key.Other lookup paths need additional structures or product features.
DocumentFetch a product with its nested attributes together.Embedding helps aggregate reads; growing arrays and duplicated shared facts need limits.
Wide-columnFetch one account's events in a bounded time range.Partition and clustering keys follow queries; large or hot partitions cause trouble.
GraphTraverse accounts connected by specific relationships.Bound traversal depth and result size; cycles and fan-out multiply work.
Time-seriesAggregate sensor readings over time windows.Time, retention, ingestion rate, and series identity shape the data.
SearchRank documents matching text.Analysis, relevance, and index freshness differ from exact record lookup.
VectorRetrieve items near an embedding.Similarity and approximate retrieval need evaluation; proximity does not establish truth.

“NoSQL” is an umbrella covering several models. It does not specify one transaction or consistency guarantee. Products also combine capabilities: SQL systems can query JSON, and document systems can enforce validation. A flexible representation still needs an application schema contract.

Consider an order whose money and customer relationship need constraints, while delivery attributes vary. This SQL runs through Python's bundled SQLite in the fixture. It combines relational integrity with a JSON field:

SQL
PRAGMA foreign_keys = ON;
CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders(
  id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id),
  total_cents INTEGER NOT NULL CHECK(total_cents >= 0),
  details TEXT NOT NULL CHECK(json_valid(details))
);
INSERT INTO customers VALUES(7, 'Ada');
INSERT INTO orders VALUES(1, 7, 1999, '{"delivery":"express"}');
SELECT c.name, o.total_cents, json_extract(o.details, '$.delivery')
FROM orders o JOIN customers c ON c.id = o.customer_id;
-- Ada | 1999 | express

The fixture rejects a negative or null total, an unknown customer, and malformed JSON. But {} is valid JSON: reading its missing delivery field returns SQL NULL. Syntax validation alone does not enforce required properties or types. Embedding a customer's current name into every order would also introduce an update question: should old orders change when the customer changes their name, or preserve a historical snapshot?

For graph-shaped data, the interesting operation may instead be “who is within two follows of Ada?” Here is a relational implementation using an edge table, included in the default run:

SQL
CREATE TABLE follows(src TEXT, dst TEXT, PRIMARY KEY(src, dst));
INSERT INTO follows VALUES('Ada','Lin'), ('Lin','Sam'), ('Sam','Ada');
WITH RECURSIVE reach(name, depth) AS (
  SELECT dst, 1 FROM follows WHERE src = 'Ada'
  UNION ALL
  SELECT f.dst, r.depth + 1 FROM reach r
  JOIN follows f ON f.src = r.name WHERE r.depth < 2
)
SELECT name, depth FROM reach ORDER BY depth, name;
-- Lin | 1; Sam | 2

The depth bound prevents this cycle from producing unlimited walks. It does not make arbitrary high-fan-out graphs cheap; this tiny fixture has one outgoing edge per person. A graph database makes nodes, relationships, and path patterns central to its query model. Against an existing empty disposable Neo4j database with write access, the equivalent exercise is:

cypher
CREATE (:Person {name:'Ada'})-[:FOLLOWS]->(:Person {name:'Lin'})
       -[:FOLLOWS]->(:Person {name:'Sam'});
MATCH (:Person {name:'Ada'})-[:FOLLOWS*1..2]->(p)
RETURN DISTINCT p.name ORDER BY p.name;
// Lin, Sam; rerunning CREATE adds more nodes and relationships.

This optional query requires Neo4j and a query client; the default fixture verifies SQLite traversal, not a graph engine. The choice depends on the sustained query workload and operations burden, not whether one query is expressible in both systems. Indexes and MVCC develops access paths and transaction visibility further.

The working set and the acknowledgement boundary

“Fast disk” is incomplete without the request shape. Many small random reads stress operations per second and latency; a sequential scan may be limited by bytes per second. HDD seeks make random access especially expensive. SSDs reduce that penalty, but still have throughput limits and queues. A larger queue can raise observed latency even when the device is busy completing operations.

The working set is the actively accessed subset of the data. Database buffers and the operating system's page cache can serve it from RAM even when the database is much larger. Repeating a query against warmed caches does not measure raw device speed. Numbers to Know separates IOPS, request size, throughput, and specific hardware limits.

Writes also cross layers. Returning from a buffered language write can mean only that bytes reached an application buffer. Flushing that buffer transfers them to the OS; storage synchronization crosses another boundary. Journals, indexes, and compaction can cause more physical writes than the application's changed bytes, called write amplification.

For an existing empty disposable receipt.txt, this is the core of the fixture's write path:

Python
import os

def save_receipt(path, sync=os.fsync):
    with open(path, "r+b") as stream:
        stream.write(b"paid\n")
        stream.truncate()
        stream.flush()             # Python buffer -> operating system
        sync(stream.fileno())      # request storage synchronization
    return "saved"                 # reached only after successful sync

The full fixture starts another process and asserts it reads exactly b"paid\n". It also injects a synchronization error and asserts no acknowledgement is returned. Failure can leave visible bytes behind; absence of a success response does not prove absence of a write.

Reopening proves execution and visibility, not survival of a power cut. This in-place update also offers no atomic replacement guarantee. On Linux, creating or renaming a file requires attention to directory synchronization as well as file synchronization. A complete crash-safe update protocol depends on the filesystem, OS, and storage stack. Let write and sync failures propagate to the caller instead of acknowledging data whose required durability boundary was not met.

Define the failure before choosing the service

Persistence means surviving a stated lifetime boundary. Durability concerns acknowledged data surviving specified failures. Backup provides a recoverable historical copy. Keeping bytes after process exit does not establish recovery from device loss, and successfully preserving an accidental deletion does not recover yesterday's data. The fixture copies, deletes, and restores a receipt to test that basic recovery path; its backup lives on the same filesystem and cannot protect against losing that filesystem.

EC2 instance-store data survives reboot, but not stop, hibernation, termination, or failure of its underlying device. EBS volumes persist through stopping an instance; on termination, each volume's DeleteOnTermination setting controls deletion. Inspect that setting rather than treating an attached volume as inherently permanent. Neither EBS nor S3 automatically gives an application the historical copies, retention, and tested restore procedure it needs.

Interfaces change the update protocol too. A file supports an offset write; a normal object replacement supplies a complete new value. The fixture's deliberately small in-memory ObjectContract demonstrates that distinction:

Python
path.write_bytes(b"AAAA:old:ZZZZ")
with path.open("r+b") as stream:
    stream.seek(5)
    stream.write(b"new")
assert path.read_bytes() == b"AAAA:new:ZZZZ"

store = ObjectContract()  # teaching API, not an S3 emulator
store.put("reports/receipt", b"AAAA:old:ZZZZ")
store.put("reports/receipt", b"new")
assert store.get("reports/receipt") == b"new"
# get("reports/") raises KeyError: the prefix is not an object.

For general purpose S3 buckets, slashes in keys express prefixes rather than POSIX directories. Successful object writes and deletes have strong subsequent read/list consistency; updates to one key are atomic. Those properties do not create transactions across keys. Publishing a manifest before all of its referenced objects exist can therefore expose incomplete application state.

To inspect an actual existing, readable, nonarchived object, use AWS CLI with configured region/credentials, s3:GetObject, and applicable encryption permissions. In a disposable local directory, set BUCKET and KEY, then run these optional read-only remote operations:

Shell
aws s3api head-object --bucket "$BUCKET" --key "$KEY"
aws s3api get-object --bucket "$BUCKET" --key "$KEY" downloaded.bin
cmp expected.bin downloaded.bin
# cmp exits 0 when bytes match your known local expected.bin fixture.

Do not assume the ETag is an MD5 checksum. Do not infer authorization from upload ownership either: with Bucket owner enforced, ACLs are disabled and the bucket owner owns the objects; policies determine access. Check the existing bucket's settings. The local model verifies none of these S3 guarantees, and the default run makes no AWS calls.

Large artifacts need their content as well as their history

Git LFS stores a small pointer in Git while the large content lives separately in LFS storage. The pointer records a format version, a content hash, and the content size. This supports versioned assets; it does not provide application queries or database transactions.

The fixture computes a canonical pointer for a nonempty asset, then verifies the referenced bytes:

Python
import hashlib

data = b"asset-v1\n" * 1024
pointer = (
    "version https://git-lfs.github.com/spec/v1\n"
    f"oid sha256:{hashlib.sha256(data).hexdigest()}\n"
    f"size {len(data)}\n"
)
assert len(data) == 9216
# The fixture rejects altered content and a mismatched size.

This is a pointer/content integrity exercise, not an LFS implementation. With Git LFS already installed, the real local commands are git lfs pointer --file=asset.bin > asset.pointer and git lfs pointer --check --strict --file=asset.pointer. They need no remote. Fetching the actual asset needs its content, an accessible LFS endpoint, and authorization.

A Git-only archive may faithfully preserve every pointer while omitting the large objects. The fixture deletes the asset and confirms the pointer cannot reconstruct it. An artifact recovery check must retrieve the objects too, then validate their hashes and sizes. The same question applies throughout storage design: which exact bytes can be recovered after the specific failure you care about?

ContinueNumbers to Know