Loams v0.1.0 is out: run the whole stack yourself with Docker Compose. See the roadmap

Blog/Research

How Postgres runs on object storage: Neon, Aurora and our hard fork

How Neon splits Postgres into a stateless compute, a Paxos WAL service and a pageserver that keeps layer files in a bucket; how Aurora, AlloyDB, OrioleDB, Xata, Nile, Lakebase and PlanetScale answer the same question; and what Loams changed in Loams Postgres, its hard fork of Neon, and why.

How Postgres runs on object storage: Neon, Aurora and our hard fork
On this page
  1. Two ways to take the disk away
  2. Neon in four parts
  3. The compute: Postgres without a data directory
  4. Safekeepers: a Paxos-replicated WAL
  5. The pageserver: WAL into layer files
  6. Reads are GetPage@LSN, and WAL redo makes pages
  7. Branching is a timeline with an ancestor
  8. Holding it together
  9. The other answers
  10. Aurora: the log is the database
  11. AlloyDB: the same idea, Google's storage
  12. Supabase and OrioleDB: a new engine inside Postgres
  13. Xata: copy-on-write in the block layer
  14. Nile: tenant-aware pages
  15. Lakebase: Neon inside Databricks
  16. PlanetScale Postgres: the case for local disks
  17. Side by side
  18. Why Loams forked Neon
  19. What we changed in the fork
  20. What we put around it
  21. What we did not change, and what it costs
  22. Sources

Postgres was written for one machine with one disk. Its buffer manager reads 8 KB pages from files in a data directory, its write-ahead log (WAL) goes to the same machine, checkpoints flush dirty pages back, and a crash is recovered by replaying WAL from the last checkpoint. Replicas, backups and point-in-time recovery are all built on that picture.

Putting that database on object storage is not a matter of mounting a bucket. S3-style storage has high latency and a price per request and no in-place writes, the opposite of what random 8 KB page I/O wants. Every serious design therefore changes what Postgres writes, not just where. This post walks through Neon's design, because Loams Postgres is a hard fork of it, compares the other answers on the market, and ends with what we changed in the fork and why.

Neon sourceneondatabase/neon (Apache-2.0); its docs/ folder is the best primary source on the storage design
Our forkostrium-labs/loams-postgres, forked at Neon's fa504217 (tag neon-fork-point)
PapersAurora, SIGMOD 2017 and SIGMOD 2018
In LoamsLoams Postgres storage, computes and control plane in the 0.1.0 compose stack

Two ways to take the disk away

There are two broad families.

The log is the database. The compute sends only WAL to a storage service, which turns WAL into pages itself; the compute never writes a data page anywhere durable. Aurora introduced this shape, and AlloyDB and Neon follow it. The network carries a fraction of the bytes, crash recovery mostly leaves the compute, and storage can keep history cheaply.

The disk is still a disk. Postgres runs unmodified on a block device, and the clever part is in the block layer: copy-on-write volumes and snapshots. Xata works this way. PlanetScale goes further and argues for local NVMe with replication for durability. These designs keep Postgres untouched and give up some of what the first family gets for free.

Neon is the clearest open example of the first family, so it is worth reading slowly.

Neon in four parts

Neon splits a Postgres server into a compute that runs Postgres, a WAL service made of safekeepers, and a pageserver that turns WAL into pages and keeps them in object storage. A storage controller places tenants on pageservers and a storage broker spreads progress information between them. Heikki Linnakangas, one of Neon's founders, laid out the reasoning in Architecture decisions in Neon (2022): no SANs and no third-party file systems, but a storage layer that understands Postgres WAL.

WAL goes out, pages come back. A commit waits for two of three safekeepers, not for the pageserver or the bucket. The pageserver turns WAL into immutable layer files in the bucket and answers GetPage@LSN, replaying records over an older page image when it has to.

The compute: Postgres without a data directory

A Neon compute is Postgres with a set of patches and the neon extension. The patches hook the storage manager (smgr), the layer between the buffer manager and the files, so a missing page is fetched over the network instead of read from a file (core changes). The compute keeps shared buffers plus a larger file cache on local disk (the compute cache), but nothing on that disk matters for durability. Lose the compute and you lose a cache.

That is what makes scale to zero possible. Neon's docs say a compute is suspended after 300 seconds without activity by default, and a new connection wakes it in a few hundred milliseconds; an idle-in-transaction session or a logical replication subscriber counts as activity.

Safekeepers: a Paxos-replicated WAL

A commit cannot wait for the pageserver to digest WAL, let alone for an upload to S3. Neon solves this with a separate WAL service. Inside the compute, a background worker called the walproposer pushes WAL to the safekeepers, much as a primary streams to a synchronous standby but initiated by the primary. Neon's WAL service document puts the rule plainly: a record is durable once a majority of safekeepers have flushed it to local disk, and "a consensus algorithm based on Paxos is used to manage the quorum". A usual deployment runs three safekeepers, so a commit waits for two.

Why consensus when there is only one writer? Because there are moments with two: a compute being replaced and its successor can both be alive, and the protocol's terms and elections let only one of them append. Safekeepers never talk to each other directly; the proposer drives recovery. The protocol has a TLA+ specification in safekeeper/spec, the kind of care a component that decides durability deserves.

Safekeepers are a holding area, not an archive: WAL is trimmed once the pageserver has processed it and the resulting layers are in object storage.

The pageserver: WAL into layer files

The pageserver is where Neon's storage engine lives. Its storage document describes the pipeline:

  1. WAL arrives from the safekeepers and is decoded. Each record is sliced by the relation and block it touches, and kept in an in-memory layer, which works as a reorder buffer.
  2. When enough has accumulated, the in-memory layer is frozen and written as an immutable L0 delta layer: all keys, for a slice of LSNs.
  3. Compaction merges L0 files into L1 delta layers that each cover a narrower key range over a longer LSN range, and writes image layers, snapshots of every key in a range at one LSN.
  4. Layer files are uploaded to object storage, with an index_part.json per timeline that lists them. Garbage collection removes layers that no retained history needs.

Picture a plane with keys on one axis and LSNs on the other. Every layer file is a rectangle in it (an image layer is a thin vertical line), never modified after it is written. Linnakangas calls the pageservers "just a cache of what's stored in the object storage": the bucket holds the truth, and local layers can be evicted and fetched again on demand.

Reads are GetPage@LSN, and WAL redo makes pages

When the compute misses a page in its caches, it asks the pageserver for that page at an LSN: a relation, a block and the LSN the compute needs to see. That LSN is what makes a stateless compute correct. The compute tracks the last-written LSN of the pages it evicted (core changes), so it never reads a version older than it wrote, and the pageserver holds a request until its WAL ingest has reached that LSN. Backpressure settings bound how far behind it can fall.

To answer, the pageserver finds the newest image of the page at or before the LSN in its layer map, collects the WAL records after it, and replays them with Postgres itself: a Postgres binary in a special WAL-redo mode, one process per tenant, restricted with seccomp to no file system or network access, fed over a pipe (walredo document). A few record types, such as SLRU commit records, are handled in Rust instead.

Reusing Postgres' redo code means Neon never reimplements the semantics of every heap, index and visibility map record. The cost is that the pageserver is tied to each major's exact WAL format, which is why porting a major version to Neon is real work.

Branching is a timeline with an ancestor

Because the pageserver keeps history as immutable layers, a copy of the database at some LSN does not need to copy anything. A Neon branch is a new timeline whose metadata says: my ancestor is timeline A, at LSN X. Writes to the branch go into the branch's own layers. A read on the branch looks in its own layers first and, for anything older, in the ancestor's layers capped at the branch point. The Neon README shows it with neon_local timeline branch: the new branch sees the parent's rows, and writes on either side are invisible to the other.

Creating a branch writes metadata, not data: a new timeline id, its ancestor and the ancestor LSN. Writes on the branch become the branch's own layers. A read on the branch searches its own layers first and then the ancestor's, capped at the branch point, so later writes on main never show through.

Neon's branching docs describe branches as instant copy-on-write clones that add no load to the parent. Point-in-time recovery is the same mechanism: branch at the LSN for a time inside the history window (by default 6 hours on Neon's free plan, 1 day on paid plans). The layers already are the backup. What branching cannot do is merge: two diverged timelines are two Postgres histories.

Holding it together

The storage controller assigns tenants to pageservers, splits large tenants into shards, and uses generation numbers so a pageserver that lost its assignment cannot overwrite the new owner's objects. The storage broker shares per-timeline progress between safekeepers and pageservers. compute_ctl, inside every compute, takes a compute spec (roles, databases, settings, the timeline), starts Postgres and reports its status. Neon's control plane, which creates projects and starts and stops computes, was never open source; anyone running Neon's storage elsewhere has to write that part.

The other answers

Aurora: the log is the database

Aurora's SIGMOD 2017 paper starts the first family; its section 3 is titled "The log is the database". Storage nodes apply redo records to pages in the background and on demand, and "the only writes that cross the network are redo log records". In the paper's benchmark Aurora sustained 35 times more transactions than mirrored MySQL with 7.7 times fewer I/Os per transaction.

Durability comes from a quorum, not from consensus. The volume is cut into 10 GB segments; each is replicated six ways into a protection group, two copies in each of three availability zones. Writes need four of six votes and reads three of six, so Aurora can lose an entire AZ plus one more node without losing data (the paper calls this AZ+1), and an entire AZ without losing the ability to write. The 2018 paper explains how Aurora avoids distributed consensus on the I/O path: because the database instance knows which storage nodes are complete up to which LSN, a normal read goes to one segment, not a quorum.

Compared with Neon: both send only WAL and materialize pages in storage. Aurora replicates pages six ways, with log and pages on the same storage nodes. Neon replicates WAL three ways, keeps pages once in an object store that provides its own durability, and treats the pageserver as a cache. Aurora's clones are copy-on-write too, but they clone a cluster's volume rather than make cheap timelines by the dozen. Aurora Serverless v2 can scale to 0 ACUs since November 2024; AWS notes that resuming can take up to 15 seconds.

AlloyDB: the same idea, Google's storage

Google's AlloyDB under the hood post (2022) describes three storage parts: a low-latency regional log storage service, a log processing service (LPS) that applies Postgres redo logic to produce materialized blocks, and sharded regional block storage. A commit is acknowledged once its WAL is in the log store; the LPS picks it up asynchronously. Each zone holds a complete copy of the database state, so reads never cross zones and need no read quorum. Reads carry an LSN, exactly as in Neon. Read pool instances attach to the same storage without private copies, and the primary streams WAL to them to keep their caches current.

Like Neon, AlloyDB runs Postgres' own redo logic in storage. Its goals differ: a high-end managed database with a columnar engine and a large cache, not a scale-to-zero, branch-per-pull-request product.

Supabase and OrioleDB: a new engine inside Postgres

Supabase runs ordinary Postgres, and its interest in storage goes through OrioleDB, whose team Supabase acquired in April 2024. OrioleDB attacks a different problem: it is a table access method (CREATE TABLE … USING orioledb) with undo-log MVCC instead of heap bloat, 64-bit transaction ids, row-level WAL, copy-on-write checkpoints and no buffer mapping. The acquisition post describes experimental decoupled storage, with hot data cached locally and synced to S3 asynchronously. OrioleDB is dual-licensed Apache-2.0 or PostgreSQL License, is in public beta and "not recommended for production usage", and still needs a patched Postgres. It replaces the engine inside Postgres; Neon replaces what is under it.

Xata: copy-on-write in the block layer

Xata's open-source platform, Apache-2.0, makes the opposite trade. It runs "vanilla Postgres without any modifications" on CloudNativePG and does branching in the storage layer: a branch is a copy-on-write snapshot of the volume, which copies blocks only as either side writes. The open-source version uses OpenEBS volumes (local NVMe, replicated by Mayastor over NVMe over fabrics); Xata's own storage engine stays private. A plugin hibernates idle branches. Every extension and version works as is; the cost is that history is snapshots, not every LSN, on block devices rather than a bucket.

Nile: tenant-aware pages

Nile builds Postgres for multi-tenant SaaS. Its architecture page describes a compute layer modified so that each page of a tenant table holds rows of one tenant only, a storage fleet that holds those pages, a separate log, and both archived to S3. Because a tenant's pages are its own, moving a tenant between computes means moving its leadership while both keep pointing at the same pages. It is the same disaggregation as Neon's, with the page format bent towards one access pattern.

Lakebase: Neon inside Databricks

Databricks announced its acquisition of Neon on 14 May 2025. Lakebase, its managed Postgres, is built on it; Neon's docs say the two share "the same infrastructure, the same technology, and the same core feature set". The release notes list general availability on AWS on 22 January 2026, with scale to zero, instant branching and point-in-time recovery; Azure followed in March. Architecturally it is Neon inside a lakehouse platform.

PlanetScale Postgres: the case for local disks

PlanetScale for Postgres (private preview July 2025) is the useful counterpoint. It runs Postgres 17 on PlanetScale Metal, whose launch post argues that network-attached volumes have high and variable latency and lower throughput than local NVMe. Durability comes from Postgres replication instead: a primary and two replicas across availability zones, automatic failover, and WAL captured continuously to object storage (Metal). Sharding is a separate project, Neki. It is the strongest argument against the first family: for latency a local disk is hard to beat, and three replicated copies are already durable. You give up scale to zero, instant branches and storage that grows without a bigger machine.

Side by side

What crosses the network on writeWho makes pagesDurable copyBranch or clonePostgres
NeonWALPageserver, Postgres WAL redoWAL on 3 safekeepers, then layers in object storageA timeline at any LSN in the windowPatched, plus the neon extension
AuroraRedo log recordsStorage nodes6 copies in 3 AZs, write quorum 4/6Copy-on-write cluster cloneAurora's own engine, Postgres-compatible
AlloyDBWALLog processing serviceRegional log store, then regional block storageCluster clones and backupsGoogle's modified Postgres
OrioleDBRow-level WALPostgres with a new table engineLocal disk; S3 mode experimentalNot the pointPatched Postgres, beta
XataBlocksPostgres, unmodifiedReplicated block volumesCopy-on-write volume snapshotVanilla
NileWAL to a logStorage fleet of tenant pagesLog and pages archived to S3Tenant-level operationsModified Postgres
LakebaseWALNeon's pageserverAs NeonAs NeonAs Neon
PlanetScaleBlocks to local NVMe; WAL to replicasPostgres, unmodifiedPrimary and two replicas; WAL in object storageNot the pointVanilla, Postgres 17

Why Loams forked Neon

Loams keeps every durable byte in your bucket, and we wanted a real Postgres there too, branchable and able to sit at zero when nobody uses it. Neon is the design that does that, it is Apache-2.0, and its storage engine is written by people who know Postgres internals better than almost anyone. We forked it on 29 September 2026 (decision D231) to run it under our own control plane.

Ten days later it became a hard fork (design 51, D820 to D824). The reason was practical: the public neondatabase/neon repository had a dozen commits in the year after its last public image build (August 2025) and none after 31 August 2026, the commit we tagged neon-fork-point, and the images we pinned carried Postgres 17.5. A fork waiting on upstream merges would wait for nothing. So the repository is ours and called Loams Postgres, nothing is merged from upstream (a future upstream commit could only come in as a hand-picked patch), and security and bug fixes are ours alone. The NOTICE keeps "Based on Neon by Databricks/Neon Inc.", and the README states that the project is independent of, and not endorsed by, Neon or Databricks.

None of this is a criticism of Neon. Its engineers built and published the best open design for this problem, and everything below starts from their work.

What we changed in the fork

Our own builds and releases. The fork builds multi-arch images, loams-postgres-storage and one loams-postgres-compute-v<N> per major, published by digest, signed keylessly with cosign, with provenance and an SPDX SBOM. Releases are tags named lp-YYYY.MM.N, cut by one workflow that runs the test gate, builds, runs Loams' integration tests on exactly those digests, and only then tags and opens a pin pull request on Loams (release.md). Compute images follow every Postgres minor within 7 days.

One pin file. Loams reads every image digest and the supported majors from one generated file, deploy/pins/loams-postgres-images.toml; CI fails if any consumer disagrees with it. Our first own build, lp-2026.10.1, kept the Postgres revisions of Neon's last public build, 16.9 and 17.5. On dev today the file pins lp-2026.11.0, cut on 18 October with Postgres 16.15 and 17.11.

Postgres minors, merged, never rebased. The fork's Postgres has one branch per major, loams/pg16 and loams/pg17. Each upstream minor is merged in CI onto a staging branch, gated, then fast-forwarded (postgres-branches.md). Starting from the heads of Neon's own Postgres branches failed the gate: they contain changes that need pgxn/neon changes Neon never published. So the branches start from the Postgres revision and extension Neon pinned together, with the minors on top.

The VM-clear choice in 17.11. Postgres 17.11 includes upstream commit c0d9864f: heap WAL records that clear visibility-map bits now register the VM page as an extra block reference, because incremental backups only see registered blocks and a torn VM page on local disk could go unnoticed. Neither can happen in Neon's model. The fork writes those heap records as its own RM_NEON_ID records with flags naming the cleared bits; the pageserver derives the VM change from the flags and stores it on the VM page; a compute WAL-logs a dirty VM page as a full image when it evicts it; and a compute has no incremental base backups. Worse, the extra reference would break the fork, because the decoder takes an update's old heap block from the last block reference. So the ruling keeps the fork's record format and reverts only that change, with a test that checks VM bits and index-only scans on a branch, the primary and a replica, and fails if a heap record ever gains a VM block reference.

A patch queue and a gate. Every change after the fork point carries a Loams-Patch: LP-<nnnn> trailer and a row in LOAMS_PATCHES.md, our Apache-2.0 statement of changes; CI fails an unlisted commit. The gate has four tiers: lint and licence checks, Rust unit tests, a fixed subset of Neon's regression suite per major, and Loams' end-to-end tests on the candidate images.

Clean inputs. Profiling is built without the flamegraph feature, so no CDDL code is in a binary we publish. Every git dependency points at an ostrium-labs fork by revision, and no build step downloads from neondatabase.

Names, carefully. The repository, images, tags and patch ids are renamed. Anything stored somewhere is not: wire protocols, layer files, the neon_rmgr records, the neon extension, neon.* settings and the neon_superuser role keep their names, since a setting in a saved compute spec or a role in a dump cannot be renamed in place.

Majors. Postgres 17 is supported and the default, 16 is in maintenance (minors and export, no new projects), and 14 and 15 are not built. PG 18 and 19 are planned.

What we put around it

Loams keeps Neon's compute, pageserver and formats, and adds its own control plane. In the design, loams-wal takes the safekeepers' place, with a TiKV quorum as the hot tier. The 0.1.0 compose stack still runs one stock safekeeper while loams-wal is measured against its launch targets.

A control plane: pg-control. Neon's control plane was closed, so Loams wrote its own. pg-control serves loams.postgres.v1: projects (one Neon tenant each), branches (one timeline each, made at an LSN or a time, with protection and expiry), endpoints (a stable address and its settings), and computes, the disposable Postgres process behind an endpoint, along with roles and databases. It runs level-triggered reconcilers under a fenced lease per project, so exactly one instance acts on a project at a time.

On-demand computes and suspend/resume. An endpoint moves through idle → starting → running → suspending → suspended. pg-control reads compute_ctl's last_active, which moves forward while a backend is busy, a transaction is open, a logical subscriber streams or autovacuum works, so a long query counts for as long as it runs. After suspend_timeout (300 seconds by default, at least 60, 0 for never) the compute is suspended, and it is started again on demand. In the 0.1.0 compose stack this is --profile postgres-control; the container runtime's socket goes only to a separate runner, pg-runner, which starts nothing but the pinned compute images.

Branching as a tool, not only a feature. Because a branch is free, Loams uses it as a safety net. loams upgrade drains Postgres and branches it before it upgrades a self-hosted stack, so a rollback has a point to return to. The API's restore is designed the same way: branch at the chosen LSN or time, swap, and keep the state before the restore as a backup branch.

A different WAL service. This is the largest change in the design, and it is still in progress. Loams' decision is that loams-wal replaces the safekeepers (design 46, D714). It speaks the safekeeper protocol, so the walproposer and the pageserver are unmodified. It is one logical acceptor per timeline, served by a stateless shared service, and it gets durability from a quorum underneath rather than from three acceptors. The default candidate writes WAL into a TiKV keyspace with one-phase transactions, which checks the writer's term fence and appends in one atomic step, and then group-commits the WAL to the bucket every 250 ms. A native leaderless store on local NVMe is developed beside it, and a benchmark against absolute targets picks one: among them p99 commit latency of 10 ms or less for one client, 100 MB/s per timeline on a bulk load, and a commit stall of at most 2 seconds when a TiKV leader is killed. Why bother, when safekeepers work? They are a stateful fleet with membership changes to operate, their bucket copy is segments behind rather than seconds, and Loams already runs a replicated TiKV that can carry the hot tier. Until the gate is green, the 0.1.0 compose stack runs one stock safekeeper.

What we did not change, and what it costs

The storage engine is Neon's: layer files, compaction, GetPage@LSN, WAL redo and timelines are unchanged, and we intend to keep them so. Walproposer and the pageserver's WAL receiver are untouched, because the safekeeper protocol is a clean seam. We do not use Neon's VM-based autoscaling; the design resizes compute pods in place.

A hard fork has a price, and the design writes it down: about 0.7 to 1.1 engineers a year, because no upstream supplies fixes any more. That covers minors within a week, security response, extensions and every new major. It is the main risk of this choice, and why the fork had a patch queue, a gate and a release workflow from its first week.

The architecture's limits stay too. A cold start is a few hundred milliseconds at best, plus warming a cache that suspension threw away. A read that misses every cache crosses the network, and one that misses the pageserver goes to the bucket. Branches cannot be merged. Every major costs a port. In return you get a Postgres whose durable state is in your bucket, whose history is a branch away, and whose compute can be nothing at all when nobody uses it. For an agent that wants a database per task, or a team that wants one per pull request, that is the right trade.

Sources

More from the blog