AWS Shipped a DynamoDB API That Stores Everything in Postgres. Read the Tables It Writes
Book: Database Playbook Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub On May 20, 2026, two members of the DynamoDB team published ExtendDB: an Apache 2.0 server that speaks the DynamoDB wire protocol and keeps every item in PostgreSQL. Your SDK thinks it is talking to DynamoDB. Underneath sits a Postgres database you can open with psql. That second part is the interesting one. DynamoDB has never let you look at how it stores your items. ExtendDB has to put them somewhere, and that somewhere is a set of ordinary SQL tables. If you have ever drawn a single-table design on a whiteboard and wondered what it "really" is, you can now read the answer in \d. What ExtendDB is, and what it says about itself ExtendDB is written in Rust, maintained by AWS, and described in its README as a clean-room implementation of the DynamoDB wire protocol. The launch announcement called it version 0.1. As of today the releases page lists v0.1.13, published October 1, so it is still a 0.1 line. It covers tables, items, Query and Scan, batch and transactional writes, streams, TTL, and vector search on pgvector. PostgreSQL 14+ is the reference backend. The README also covers MongoDB and mentions SQLite. TLS is mandatory, and every request must be SigV4-signed with credentials ExtendDB issues itself. The launch post is plain about its status: "a v0.1 release for development, testing, and experimentation," and "ExtendDB is not DynamoDB." Run it and point the AWS CLI at it The repo ships a docker-compose.yml that runs Postgres 16 and ExtendDB side by side under Docker Compose and bootstraps on first start. The up and logs commands come from the project's Docker Compose guide: git clone https://github.com/ExtendDB/extenddb.git cd extenddb EXTENDDB_IMAGE=extenddb/extenddb-postgres:latest \ docker compose up -d docker compose logs extenddb-bootstrap The bootstrap logs print the account ID. The admin password defaults to ExtendDBLocalAdmin123 (override it with EXTENDDB_ADMIN_PASSWORD). Create a user, give it a policy, and mint an access key: edb() { docker compose exec extenddb extenddb manage \ --user admin --password ExtendDBLocalAdmin123 "$@" } edb create-user --account-id \ --user-name dev edb put-user-policy --account-id \ --user-name dev --policy-name full \ --policy-document '{"Version":"2012-10-17", "Statement":[{"Effect":"Allow", "Action":"dynamodb:*","Resource":"*"}]}' edb create-access-key --account-id \ --user-name dev The secret is shown once. Access keys start with AKIAEXTENDDB, per the differences doc, so a leaked one is easy to tell apart from a real AWS key. Now trust the self-signed certificate and point the stock AWS CLI at the server: docker compose exec extenddb cat \ /var/lib/extenddb/.extenddb/tls/cert.pem \ > extenddb-cert.pem export AWS_CA_BUNDLE="$PWD/extenddb-cert.pem" export AWS_ACCESS_KEY_ID= export AWS_SECRET_ACCESS_KEY= export AWS_DEFAULT_REGION=us-east-1 aws dynamodb list-tables \ --endpoint-url https://127.0.0.1:18443 That is the whole integration: an endpoint flag and a CA bundle. To skip the flag on every command, the getting-started guide sets AWS_ENDPOINT_URL_DYNAMODB=https://127.0.0.1:18443 instead. The rest of this post assumes you did. A single-table design with one GSI Model a small shop. Customers and their orders share a partition, and a GSI answers "which orders are in status X." Save the index definition as gsi.json: [ { "IndexName": "GSI1", "KeySchema": [ {"AttributeName": "GSI1PK", "KeyType": "HASH"}, {"AttributeName": "GSI1SK", "KeyType": "RANGE"} ], "Projection": {"ProjectionType": "ALL"} } ] Create the table and wait for it. ExtendDB holds new tables in CREATING for 5 seconds by default, which the differences doc lists as configurable: aws dynamodb create-table --table-name Shop \ --billing-mode PAY_PER_REQUEST \ --attribute-definitions \ AttributeName=PK,AttributeType=S \ AttributeName=SK,AttributeType=S \ AttributeName=GSI1PK,AttributeType=S \ AttributeName=GSI1SK,AttributeType=S \ --key-schema \ AttributeName=PK,KeyType=HASH \ AttributeName=SK,KeyType=RANGE \ --global-secondary-indexes file://gsi.json aws dynamodb wait table-exists --table-name Shop Write three items: a profile with no GSI keys, and two orders that carry them. Save as items.json: { "Shop": [ {"PutRequest": {"Item": { "PK": {"S": "CUSTOMER#42"}, "SK": {"S": "PROFILE"}, "plan": {"S": "pro"} }}}, {"PutRequest": {"Item": { "PK": {"S": "CUSTOMER#42"}, "SK": {"S": "ORDER#2026-10-01#A17"}, "GSI1PK": {"S": "STATUS#PAID"}, "GSI1SK": {"S": "2026-10-01#A17"}, "total": {"N": "59.90"} }}}, {"PutRequest": {"Item": { "PK": {"S": "CUSTOMER#42"}, "SK": {"S": "ORDER#2026-10-09#B03"}, "GSI1PK": {"S": "STATUS#SHIPPED"}, "GSI1SK": {"S": "2026-10-09#B03"}, "total": {"N": "12.00"} }}} ] } Then load it and run the two access patterns: aws dynamodb batch-write-item \ --request-items file://items.json # all orders for one customer, oldest first aws dynamodb query --table-name Shop \ --key-condition-expression \ "PK = :pk AND begins_with(SK, :o)" \ --expression-attribute-values \ '{":pk":{"S":"CUSTOMER#42"},":o":{"S":"ORDER#"}}' # every paid order, via the index aws dynamodb query --table-name Shop \ --index-name GSI1 \ --key-condition-expression "GSI1PK = :s" \ --expression-attribute-values \ '{":s":{"S":"STATUS#PAID"}}' Under DynamoDB semantics the first query returns the two orders and skips the profile, and the second returns order A17 only. That is the behavior to check for. None of this is ExtendDB-specific: the same commands run against DynamoDB. Open psql and read the tables The Postgres container publishes no port, so exec into it. List the databases first: docker compose exec postgres psql -U postgres -l You should see a catalog database (extenddb_catalog in the docs) and a separate data database for items. The getting-started guide's example output names it extenddb_catalog_data; confirm it in the list. The catalog maps your table names to the UUIDs used in SQL. Its schema is in 001_schema.sql: -- psql -U postgres -d extenddb_catalog SELECT table_name, table_id FROM tables; SELECT index_name, index_id, propagation_delay_ms FROM indexes; Now switch to the data database. Each DynamoDB table and each index becomes its own SQL table named "_ddb_": -- psql -U postgres -d \dt \d "_ddb_" SELECT pk, sk_s, jsonb_pretty(item_data) FROM "_ddb_"; Here is what to expect from \d, reconstructed from ddl.rs and abridged. Trust your own \d output over this sketch: -- base table "Shop" (string sort key) CREATE TABLE "_ddb_" ( pk TEXT COLLATE "C" NOT NULL, sk_s TEXT COLLATE "C", sk_n NUMERIC, sk_b BYTEA, item_data JSONB NOT NULL, PRIMARY KEY (pk, sk_s) ); -- index "GSI1" CREATE TABLE "_ddb_" ( pk TEXT COLLATE "C" NOT NULL, sk_s TEXT COLLATE "C", base_pk TEXT COLLATE "C" NOT NULL, base_sk_s TEXT COLLATE "C", item_data JSONB NOT NULL, PRIMARY KEY (pk, base_pk, base_sk_s) -- plus the unused typed sort-key columns ); CREATE INDEX ON "_ddb_" (pk, sk_s, base_pk, base_sk_s); Four things are worth staring at. The sort key has three typed slots. The storage design doc says only one of sk_s, sk_n, sk_b is filled per table, picked by the sort key's declared type. Numbers go in NUMERIC, so 10 sorts after 9. That matches DynamoDB, which returns Number sort keys in numeric order. COLLATE "C" lets the index follow DynamoDB's sort order. DynamoDB orders String sort keys by UTF-8 bytes, per the same Query reference. Postgres's C collation sorts by byte values rather than language rules. Setting it on the column lets the B-tree index serve those byte-ordered range scans. A comment in ddl.rs notes the query builders also apply COLLATE "C" to string key predicates and ORDER BY. Older tables without it on the column still return rows in the right order; their index may just not be used as well. Every non-key attribute lives in one JSONB column. plan and total have no column and no index. The design doc says item_data holds the complete item, key attributes included. Look at the jsonb_pretty output yourself to see how types are encoded; that matters before you write any JSON-path SQL against it. The GSI is a second table, and its primary key includes the base key. GSI keys are not unique (many orders can be STATUS#PAID), so the base pk and sort key join the primary key as a tiebreaker. Every write that touches GSI1PK now writes two tables. In DynamoDB you pay that as extra write units on the index. Here you can see the rows. Count the rows in the index table. DynamoDB leaves items without the index key out of a GSI (a sparse index), so the profile should be missing. Checking that is a two-second test of how faithful the adapter is. What a key-value query looks like as SQL The design doc gives the translation table. Equality, , and BETWEEN on the sort key map to the same SQL operators. begins_with does not become LIKE. It becomes a half-open range, with the upper bound made by incrementing the last byte of the prefix. Your first query is roughly this (ExtendDB binds parameters; literals are here for reading): SELECT item_data FROM "_ddb_" WHERE pk = 'CUSTOMER#42' AND sk_s >= 'ORDER#' AND sk_s < 'ORDER$' ORDER BY sk_s; # is byte 0x23, so the bound is $ at 0x24. Run EXPLAIN on it if you like. With three rows the planner may pick a sequential scan because the table is tiny. The index is still there. This is the relational reading of single-table design. A partition key is an equality predicate on the leading column of a composite B-tree. A sort key is a range on the second column. A "good access pattern" is any question you can answer with one contiguous slice of that index. A GSI is a second copy of the data, sorted differently, kept in sync by someone. DynamoDB hides that someone. ExtendDB is the someone. Both the design doc and the differences doc say it applies GSI writes after a random delay of up to 10 ms by default. The design doc describes a propagation_delay_ms of 0 as same-transaction, while the differences doc still calls synchronous GSIs a future feature. A filter on plan or total has no column to use. The rows are read, then discarded. DynamoDB's Query reference says the same about its own filters: they run after the read, and consumed capacity is the same with or without them. ExtendDB pays the same way: Postgres reads every row in the slice before dropping the ones that miss. Where it fits, and why it is not your way off DynamoDB The uses AWS lists are local development, CI, on-premises and air-gapped deployments, and multi-cloud setups where Postgres is available. For CI, the draw is concrete: a real Postgres you already know how to run, pg_dump for fixtures, and the AWS SDK pointed at it unchanged. One of the launch authors wrote on LinkedIn, as quoted by InfoQ, that in the current version it "doesn't differ that much" from DynamoDB Local "except you can use Postgres." A migration target is a different job. The differences doc lists what is missing or behaves differently today: Global tables and PartiQL are not implemented and return UnknownOperationException. DAX is listed as not applicable. On Postgres, contended TransactWriteItems usually queue and commit instead of returning TransactionConflict. A test that expects TransactionConflict won't get one here, and code that never handles it will look fine until production. On-demand tables get a fixed burst (4,000 WCU / 12,000 RCU) with no auto-scaling, and throttling can be switched off entirely. Import and export use local paths, not S3. The README says running more than one serve against the same catalog is not supported in this release, so there is no horizontal scaling story yet. Performance is an open question. InfoQ quotes one Reddit user reporting about 300 ms p90 on update_item at a concurrency of 10 on a 16-core MacBook and calling it "not good (yet)." That is a single unverified report, not a benchmark. I haven't run ExtendDB myself, so measure on your own workload before you lean on it. And the operational trade is the one the launch post states outright: you own the database's "availability, backups, and maintenance." Leaving DynamoDB for this means trading a managed service for a self-hosted Postgres plus a 0.1 adapter in front of it. If what you actually want is Postgres, model for Postgres directly and skip the JSONB-in-a-key-value-shape layer. If this was useful Reading \d on a DynamoDB table is a fast way to see what an access pattern costs: which column carries the equality, which carries the range, and which data has no index at all. The Database Playbook works through that decision for Postgres, DynamoDB, Redis, SQLite and the rest, so you can pick the store from the access patterns instead of the other way round.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to