Supported environments
How the targets compare
Each row compares a capability of DynamoDB with its adaptation on each target. A dash means this profile does not state the capability for that target.Cloud Adapter
On Akamai, DigitalOcean, Private Kubernetes, and Scaleway
CloudNativePG
PostgreSQL in the Cloud Adapter deployment
Tensor9 deploys and operates the PostgreSQL store with the customer Cloud Adapter deployment, including disconnected deployments. Application requests use the same Rust DynamoDB-to-PostgreSQL implementation as the managed PostgreSQL target. Database access uses the configured connection credentials; Cloud SQL IAM authentication is specific to the Google-managed target. The customer supplies the infrastructure, and the deployment’s storage, backup and recovery configuration determines availability.How it works
Tensor9 runs a DynamoDB adapter beside your application in the customer’s environment. It configuresAWS_ENDPOINT_URL_DYNAMODB to send the AWS SDK’s requests to the adapter over the local loopback network. Your SDK and application code stay unchanged. The adapter accepts DynamoDB requests and data types, returns DynamoDB responses and errors, and stores data in PostgreSQL.
DynamoDB supports numbers with up to 38 significant digits, typed sets, conditional writes, secondary indexes, and atomic transactions. The adapter implements these using PostgreSQL storage and transactions. PostgreSQL supports multi-table transactions and synchronously updated indexes, but has different concurrency, capacity, and identifier limits. Unsupported requests return errors; the limitations below describe the differences.
For base-table Query and Scan requests, the adapter compiles supported filters into parameterized SQL WHERE predicates over the JSONB item. Number comparisons cast stored decimal text to PostgreSQL numeric. An order-preserving key column supplies sort bounds, cursor position, and direction. The query uses a server-side cursor to fetch bounded batches until the requested Limit or 1 MiB of item data. Expressions that cannot be represented in SQL are evaluated in the adapter over the rows selected by the SQL predicate. Secondary-index queries use the separate path described below.
For conditional writes, the adapter opens a transaction, locks the row with SELECT ... FOR UPDATE, evaluates the ConditionExpression, and writes within that transaction. This requires an extra round trip, but prevents a concurrent writer from changing the item between the check and write. A failed condition returns ConditionalCheckFailed and can return the item that failed it. Some unconditional writes skip the read: a top-level SET/REMOVE patch or integral counter add uses one statement when it has no condition, requests no returned item image, and affects no secondary index or stream.
Your application uses the DynamoDB API through an adapter in the target environment.
Architecture
The adapter is a stateless Rust service. It parses DynamoDB expressions, translates requests into PostgreSQL operations, and returns DynamoDB responses. It connects to PostgreSQL through a connection pool using the customer’s credentials. Durable state stays in PostgreSQL, so adapter instances can restart or scale out without coordinating local state. Stream-enabled tables have the separate single-writer restriction below. Each DynamoDB table has a PostgreSQL table namedddb_<name> in one database. The ddb_tables registry records its generated physical name, key schema, billing mode, time-to-live (TTL) settings, global secondary indexes (GSIs), and stream settings. A write or query for a table absent from the registry returns ResourceNotFoundException; writes do not create tables implicitly. At runtime, CreateTable creates a PostgreSQL expression index for each declared GSI. Each stream-enabled table also has a _strm table that stores change records.
A stateless Rust adapter serves the DynamoDB API in front of Postgres; all durable state lives in one Postgres database, where each DynamoDB table is its own physical relation.
The adapter first builds a storage-independent request plan, then converts it to a Postgres-specific plan for execution.
Schemas
A DynamoDB item occupies one PostgreSQL row with columnspk TEXT, sk TEXT, okey TEXT and item JSONB. The PRIMARY KEY (pk, sk) index serves GetItem. A separate index over pk and bytewise-collated okey serves ordered base-table Query requests.
The item JSONB column preserves DynamoDB’s typed attribute representation, including {"N":"123.45"}, {"B":"<b64>"}, and typed sets. Numbers remain decimal strings, so a 38-digit amount or a Snowflake ID is not rounded through a floating-point conversion. Type tags preserve the distinction between numbers and numeric strings. Conditional writes use row locks, not a separate version column. Enabling TTL builds an expression index over the configured epoch attribute inside the JSONB item. A bounded, leader-elected reaper deletes eligible rows through that index. Expired items remain readable until physical deletion; reads do not filter on TTL.
Each attribute keeps its DynamoDB type tag inside item JSONB , so {“N”:“123.45”} keeps its exact decimal string.
Where tables live
Each DynamoDB table has its own PostgreSQL table, indexes and optional_strm change-record table. CreateTable creates those database objects and their registry entry; DeleteTable drops them. Storage, indexes and vacuum settings can be tuned per table.
The adapter checks a 500-table guardrail per namespace when creating a physical DynamoDB table. Concurrent creates of different tables can exceed that guardrail; it is not a hard PostgreSQL quota. Plan database capacity and table placement for larger deployments. Provisioned instances continue to incur compute costs while idle.
Each declared table is its own physical relation. CreateTable and DeleteTable create and drop PostgreSQL relations. The adapter applies a 500-table guardrail per namespace.
Conditional writes
To evaluate a conditional write, the adapter locks the item’s(pk, sk) row with SELECT … FOR UPDATE, checks the ConditionExpression against that row, then writes and commits. The lock prevents another writer from changing the item between the check and write.
A false condition returns the non-retryable ConditionalCheckFailed error. A PostgreSQL serialization failure or lock timeout (40001/lock-not-available) instead returns a retryable throughput error, allowing the SDK to back off and retry. Concurrency failures are not reported as failed conditions.
For attribute_not_exists(pk), the PostgreSQL primary-key constraint prevents two concurrent requests from creating the same item. Only one insert can succeed for a given (pk, sk).
Failed conditions and PostgreSQL concurrency conflicts return different error types.
Secondary indexes
On each write, the adapter adds two index fields to the item’s JSONB. The partition-key field,_idx_<name>_h, normalizes equivalent values such as 1 and 1.0. The sort-key field, _idx_<name>_o, preserves sort order and uses the base key to break ties. A PostgreSQL expression index over these fields locates matching partition keys. The adapter then evaluates sort-key conditions, ordering, pagination, and any FilterExpression over the retrieved items.
The index fields and item commit in the same transaction, so GSI queries are strongly consistent. DynamoDB’s own GSIs update asynchronously and are eventually consistent. Each index lookup is limited to 10,000 candidate items for one index partition-key value. Exceeding this limit returns an error; choose index keys with this limit in mind.
A GSI uses a PostgreSQL expression index to find candidates, then the adapter filters and orders them. Index updates commit with the item, making GSI queries strongly consistent.
Transactions
TransactWriteItems executes in one PostgreSQL BEGIN…COMMIT transaction at SERIALIZABLE isolation. A debit in one table and a credit in another either both commit or both fail, as in DynamoDB.
Transactions enforce DynamoDB’s 100-action limit and one-operation-per-item rule. The adapter records ClientRequestToken in the same transaction as the writes. Within DynamoDB’s idempotency window, a retry with the same token returns the prior success without committing the writes again. The adapter acquires row locks in a consistent order to prevent opposite-direction transfers from deadlocking. Serialization or deadlock failures return retryable conflicts.
TransactWriteItems is not supported on stream-enabled tables. A request that touches any such table returns ValidationException before execution, because the transaction path does not create the required stream records.
TransactWriteItems runs in one native Postgres BEGIN…COMMIT at SERIALIZABLE, atomic and isolated across tables.
Limitations
PostgreSQL limitations- One PostgreSQL instance serves each table. Heavy contention on one key can cause serialization failures and row-lock conflicts. The adapter returns retryable errors, but sustained contention can exhaust the SDK retry budget and appear as throttling.
- Stream-enabled tables allow one writer at a time. An owner lease of about 90 seconds preserves stream record order. A second adapter instance attempting to write while that lease is active receives a retryable
ThrottlingException. Throughput depends on the writer, database and request workload. Tables without streams support concurrent writers. DescribeStreamlists at most 100 shards. A stream with more shards is not fully enumerated in one call.- Transactions on stream-enabled tables are unsupported. A
TransactWriteItemsrequest touching one returnsValidationExceptionbecause the transaction path does not create stream records. - DynamoDB backup and global-table APIs are unsupported. Use PostgreSQL backups, snapshots, point-in-time recovery (PITR), and replica/failover procedures. DynamoDB on-demand backup/restore, PITR, and global-table replication APIs are not implemented.
- Instances incur costs when idle. Size and pay for PostgreSQL capacity ahead of demand. Larger table counts require more instances and table-placement planning.
- Index lookups have a 10,000-item limit. A lookup that exceeds 10,000 candidate items for one index partition-key value returns an error.
- Plan for the table-count guardrail. The adapter checks 500 physical DynamoDB tables per namespace. Larger deployments need database capacity and table-placement planning.
- Expired items remain readable until deletion. A bounded, leader-elected reaper uses the TTL expression index to delete eligible rows asynchronously. Until physical deletion, GetItem, Query and Scan can return the expired item, and it continues to consume storage.
Other considerations
- Stream records commit with the write. The adapter stores each record in the table’s
_strmtable within the same transaction as the item write. A record exists if and only if its write committed. The single-writer lease keeps stream sequence order aligned with commit order so a cursor can resume without skipping records. - Use PostgreSQL recovery procedures. Backups, PITR, snapshots, and replica/failover procedures operate on the PostgreSQL database. The adapter does not implement DynamoDB backup, PITR, or global-table APIs.
- Connection pooling. The adapter uses pooled PostgreSQL connections. Deployments spread across many instances may need a separate pooler to keep total connections within each database’s limit.
- Migrate and test one table at a time. Separate PostgreSQL tables allow per-table export/import, dual-write, or backfill-and-switch procedures. Application code continues to use the DynamoDB API.
- Use PostgreSQL operational tools. Monitor SQL activity, maintain indexes, vacuum tables, and run backups with your existing PostgreSQL tools and procedures.
On Azure
Azure Cosmos DB (provisioned)
How it works
Tensor9 runs a DynamoDB adapter beside your application in the customer’s environment. It configuresAWS_ENDPOINT_URL_DYNAMODB to send the AWS SDK’s requests to the adapter over the local loopback network. Your SDK and application code stay unchanged. The adapter accepts DynamoDB requests and data types, returns DynamoDB responses and errors, and stores data in Azure Cosmos DB.
The adapter maps DynamoDB items and supported operations onto Cosmos documents. Native numeric precision, result pagination and transaction support differ from DynamoDB; the following sections describe those application-visible limits.
Your application uses the DynamoDB API through an adapter in the target environment.
Architecture
The adapter parses DynamoDB requests and expressions, executes Cosmos operations and returns DynamoDB responses. Durable item and registry state lives in Cosmos, and requests use the customer’s workload identity. Adapter instances can restart without recovering local data; stream-enabled tables still require one active writer. Tensor9 provisions one Cosmos account and oneddb database per stack, with a container for each declared table. The selected target sets provisioned or serverless throughput for the account. Items use table-prefixed partition keys; a reserved _tablemeta partition stores the schemas and creation times. Tables created at runtime share an existing container, as described below. LSIs share the source container; the provisioned target uses separate, asynchronously maintained GSI containers.
A stateless Rust adapter serves the DynamoDB API in front of Cosmos. One account and one “ddb” database hold a container per declared table, partitioned on /_pk. Runtime-created tables share a deployed container and its capacity.
The adapter first builds a storage-independent request plan, then converts it to a Cosmos-specific plan for execution. Each declared table has its own container; runtime-created tables share a deployed container and its capacity.
Schemas
The adapter preserves DynamoDB type tags inside theitem envelope. Accepted numbers are stored as native Cosmos JSON numbers; binary payloads use order-preserving hex. Keys and routing fields identify the logical table and partition.
Numeric precision differs: values must have at most 15 significant digits and fit the native numeric range. Values outside this supported representation fail validation before storage; the adapter does not preserve DynamoDB’s full 38-digit numeric domain as decimal strings.
Items remain limited to 400 KiB. Updates check the resulting item, including projected local-index entries where applicable. A larger Cosmos document limit does not disable this DynamoDB compatibility check.
Type tags distinguish numbers, strings, binary values and sets; native payloads make predicates queryable in Cosmos.
Where tables live
Each declared table has its own container, indexing policy and storage within the stack’s shared account and database. Containers share account administration and lifecycle. Monitor account limits as the number of tables grows. Choose provisioned or serverless throughput when selecting the target. Provisioned containers have an autoscale range of request units (RUs) derived from declared capacity; aPAY_PER_REQUEST source table gets a configurable default range. Serverless containers bill consumed requests and do not reserve the source table’s requested capacity. Throughput depends on request-unit use and partition distribution. Changing the selected target recreates the account and containers, so plan a data migration.
Within a container, a table-prefixed partition key such as orders|S:user#42 separates logical tables. Point reads and base queries use that _pk route; scans and registry reads filter on _tbl. Runtime CreateTable writes a registry document in the stack’s first deployed container. Its data identity has no permission to provision Cosmos containers. DescribeTable and ListTables read that registry.
- Key schema is required. A composite-key table’s key schema comes from a declared CreateTable, the registry doc, or is learned from the first key-bearing request. A Put that can’t resolve its key schema is rejected outright, never written under a guessed key.
- Declared tables are physically isolated; runtime-created tables are logically isolated. A declared table’s container (its throughput, its index, its storage) is its own. A table created at runtime shares a deployed container: its logical partitions are disjoint by table-prefixing, but physical partitions are shared. A hot logical partition remains bounded by the service capacity available to its physical partition.
A stack’s tables share one account and one “ddb” database, using the selected throughput mode. Each declared table has its own container; runtime-created tables share a deployed container and its capacity. At runtime, CreateTable / DeleteTable / UpdateTable change registry documents, not Cosmos resources.
Conditional writes
The operation and its options determine how the adapter writes an item. Ordinary SET updates use a versioned, partition-scoped stored procedure. Cosmos reads the current item, evaluates the condition, applies the update and checks the resulting 400 KiB item limit inside one atomic operation. This is read-modify-write inside the database, with no separate adapter read round trip. Native PATCH is narrower: the condition must prove existence, every action must be an eligible top-level removal, and TTL, streams and old-image requirements must be absent. A size-increasing SET cannot use this path because native PATCH cannot check the resulting DynamoDB item size. LSI updates, unsupported expressions and requests needing the old image on condition failure fall back to an adapter read and an etag-guarded write. Streamed writes use the guarded batch path to commit the item and outbox record together. A concurrent write conflict is retryable; it is not reported as a false condition failure. The Yahoo! Cloud Serving Benchmark (YCSB) workload F measures application-side read-modify-write: a separate GetItem before UpdateItem. Its full latency includes both API calls and is distinct from the update phase. That sequence is not a multi-item transaction. In the recorded 2026-09-09 provisioned capture with 16 workers, full read-modify-write p50 was 21.071 ms and update-phase p50 was 15.935 ms, reported as medians of three completed repetitions. These are different portions of the same workload, not two competing implementations.Secondary indexes
A local secondary index shares the source container and partition route, so its reads reflect committed writes immediately. Its projected entry size also contributes to DynamoDB’s item-size check. On the provisioned target, Azure asynchronously maintains a dedicated container for each declared global secondary index. Queries target that index container and push down its key predicate, ordering and cursor. GSI visibility is eventual;ConsistentRead=true is rejected, matching DynamoDB’s GSI contract.
Serverless tables cannot declare these managed GSIs. Index definitions must match the provisioned configuration; the adapter does not silently build a substitute index at runtime.
Transactions
The current Cosmos adapter rejects DynamoDB multi-item transactions. This includesTransactWriteItems, TransactGetItems and transactional PartiQL, even when all items share a partition key.
Cosmos partition-local stored procedures and batches still make individual writes and stream outbox commits atomic. That internal mechanism does not expose the DynamoDB transaction APIs.
Applications that require DynamoDB multi-item transactions need another listed target that supports those APIs.
Limitations
Compatibility limits- Numeric domain: at most 15 significant digits and representable native range, compared with DynamoDB’s 38-digit domain. Unsupported values fail validation.
- Item size: 400 KiB remains enforced. Ordinary SET updates use the stored procedure; LSI updates need the guarded fallback so projected entry size is checked too.
- Transactions: the current adapter rejects all DynamoDB multi-item transaction APIs, including single-partition requests.
- Read consistency: the compiled account uses Strong consistency regardless of the request’s ConsistentRead flag. Plan capacity for strongly consistent reads.
- Pagination: Query and Scan use native result windows. Limit and ScannedCount do not exactly reproduce DynamoDB’s pre-filter examined-item counter; residual predicates may be evaluated in the adapter.
- Indexes: managed GSIs are eventually consistent and require the provisioned target; LSIs share the source container. Declared index definitions must match the deployment.
- Streams: records co-commit with the item, but a streamed table permits one writing instance at a time to preserve ordering. Writes from another instance can be rejected until its writer lease is available.
- Hot partitions: capacity depends on key distribution and the account’s throughput mode. A hot item can contend even when aggregate capacity remains available.
- Table lifecycle: DeleteTable changes the logical generation. Other adapter instances can observe that change after the bounded metadata cache delay, approximately five seconds; physical cleanup is bounded.
Other considerations
- Migration: the Cosmos containers start empty. Export/import, backfill or a dual-write cutover must move existing DynamoDB data before switching traffic.
- Deployment: the compiler provisions one account with the chosen throughput mode, a shared database and containers for declared tables. Managed identities provide data access; account keys are unnecessary.
- Capacity: provisioned containers use dedicated autoscale RU bands. Serverless containers have no provisioned throughput and bill consumed requests. The 2026-09-09 adapter captures used separate Standard_D8s_v5 driver and adapter VMs in westus2, Strong consistency and gateway-mode connectivity. The provisioned container had a 40,000-RU/s autoscale maximum. YCSB used 100,000 verified records with ten 100-byte fields and 200,000 logical operations per repetition. Each mode has its own results; medians summarize three completed repetitions. There is no matching native DynamoDB baseline for this configuration, so these captures do not establish a performance advantage over DynamoDB.
- Runtime-created tables: these occupy separate logical namespaces inside an already-deployed container and share its capacity. Declared tables have separate containers.
- Operations: Microsoft operates Cosmos storage; Tensor9 operates the adapter. Monitor request units, throttling, write conflicts and latency alongside adapter and application host utilization.
Azure Cosmos DB (serverless)
Requests and database state
Your DynamoDB client sends requests to the Rust adapter in the target environment. The adapter parses DynamoDB expressions, performs Cosmos operations and returns DynamoDB responses. Cosmos stores items, table definitions and stream records. The adapter uses workload identity to access that data; restarting an adapter instance does not require restoring local item state.Choosing serverless capacity
Tensor9 provisions a serverless Cosmos account and one ddb database for the deployment. Charges follow consumed request units and stored data, without a dedicated autoscale range. The selected account mode applies to its containers; a source table’s provisioned capacity does not reserve Cosmos request units. Test the workload’s bursts, retries and partition distribution against the serverless limits.Declared and runtime-created tables
Each declared table gets a container. A table created through the runtime CreateTable API instead uses the first deployed container: the adapter records its schema and separates its items with a table-prefixed partition key. The data identity cannot provision a new Cosmos container. DescribeTable and ListTables read the durable registry; DeleteTable invalidates the table generation and performs bounded cleanup. Runtime-created tables therefore share container capacity.Items, reads and conditional writes
DynamoDB attribute type tags are retained, but numbers must fit the native Cosmos representation: at most 15 significant digits and a representable range. Unsupported numbers are rejected. The adapter enforces a 400 KiB item limit and uses strong reads regardless of ConsistentRead. Ordinary writes use a Cosmos stored procedure; eligible conditional removals use PATCH, and other request shapes use guarded read-modify-write. Query and Scan use Cosmos result windows, so Limit and ScannedCount differ from DynamoDB’s examined-item accounting.Indexes, transactions and streams
Local secondary indexes share the table’s container and reflect a write immediately. A global secondary index requires the provisioned target because the separate Azure-maintained index container needs autoscale throughput; declaring one on serverless stops the build. DynamoDB multi-item transaction APIs are rejected. Streams commit each change record with the item and require one writing instance per streamed table. All four view types and 24-hour retention are available; an older position returns TrimmedDataAccessException.Expiry, migration and operation
The adapter converts the configured absolute TTL epoch into Cosmos’s relative expiry value. Cosmos removes expired documents asynchronously. Deploy the destination, load existing items and validate the application’s queries and stream consumers before switching traffic. Changing between provisioned and serverless accounts requires a new destination and data migration. Microsoft operates Cosmos, while Tensor9 operates the adapter; configure account access, backups and monitoring for the target environment.On Google Cloud
Firestore
How it works
Tensor9 runs a DynamoDB adapter beside your application in the customer’s environment. It configuresAWS_ENDPOINT_URL_DYNAMODB to send the AWS SDK’s requests to the adapter over the local loopback network. Your SDK and application code stay unchanged. The adapter accepts DynamoDB requests and data types, returns DynamoDB responses and errors, and stores data in Google Cloud Firestore.
The adapter maps DynamoDB items, conditions, indexes and transactions onto Firestore documents. Numeric values must fit the native representation described below. Reads are strongly consistent, and write latency depends on whether the operation needs an initial read or encounters contention.
Your application uses the DynamoDB API through an adapter in the target environment.
Architecture
The adapter parses DynamoDB requests and expressions, executes Firestore operations and returns DynamoDB responses. Durable item and table state lives in Firestore. It authenticates with the customer’s workload identity. Instances can restart without recovering local data; stream-enabled tables still require one active writer. On the Firestore side there is no per-table container to provision. Every logical DynamoDB table lives in one Firestore collection,ddb, and each item is a document whose id encodes the table and the item’s key (orders|u#42), with a _tbl field that discriminates which logical table a document belongs to and a table-prefixed _pk field that routes a base Query to one partition’s documents. A reserved registry document holds the table registry (declared schemas and creation time), which is what lets a table your app creates at runtime be served with no Firestore provisioning (see the section on where tables live). Every DynamoDB item is stored so its exact types survive, and each declared secondary index is served by a Firestore composite index over the item’s existing tagged scalar fields. Because the adapter is stateless, this Firestore layout is the entire durable state of the system.
A stateless Rust adapter serves the DynamoDB API in front of Firestore; all durable state lives in one Firestore collection, “ddb”, where every logical table’s documents live together and are told apart by a _tbl field.
The adapter first builds a storage-independent request plan, then converts it to a Firestore-specific plan for execution.
Schemas
The envelope preserves DynamoDB’s type system without making values opaque to Firestore. Each attribute is a one-tag map: strings use{"S": stringValue}, numbers use {"N": integerValue|doubleValue}, binary uses {"B": bytesValue}, and sets, lists, maps, booleans, and null retain their own tags. That keeps a numeric value distinct from a numeric-looking string while allowing native filters and indexes to address paths such as item.total.N.
Native number storage has an explicit boundary. Signed 64-bit integral values remain exact Firestore integers; other numbers are accepted only inside a conservative 15-significant-digit double envelope. A DynamoDB number outside that envelope fails before storage instead of being rounded silently. The item map sits beside the routing fields id, _pk, _tbl, _okey, and _keyAttrs.
Each attribute keeps its DynamoDB type tag while scalar payloads use native Firestore values, so filters and secondary indexes can execute in storage.
Where tables live
Firestore is fully serverless: there is no account tier, no throughput mode, and no capacity band to size. Every DynamoDB table you declare is compiled into the same Firestore collection (ddb) , and each table’s documents are told apart by a _tbl discriminator and a table-prefixed document id and _pk route. Firestore shards documents automatically, and billing follows operations and stored data. Idle tables can still incur storage charges. A reserved registry document holds the declared schemas and creation times.
Runtime CreateTable writes the logical schema into the registry; DescribeTable and ListTables read it. DeleteTable invalidates the old table generation with one metadata write. Recreated tables use a new generation, such as orders#2. Old documents become invisible to the application and remain stored until bounded cleanup on a later same-name deletion.
- Key schema is required. A composite-key table’s key schema comes from a declared CreateTable, the registry document, or is learned from the first key-bearing request. A Put that can’t resolve its key schema is rejected outright, never written under a guessed key.
- Isolation is logical, not physical. Tables are kept apart by the
_tbldiscriminator and the table-prefixed id and_pk, all inside the project’s one Firestore database, which is shared across the project’s Firestore-backed apps. A pointGetItemand a baseQueryisolate by the table-prefixed route; only aScan(and the registry sweeps) filters on_tbl. Per-install physical isolation (a dedicated named database and a database-scoped identity) is not provided; the separation stays logical.
Every declared table shares one “ddb” collection, told apart by a _tbl discriminator; Firestore is fully serverless, so there is no throughput mode to pick. At runtime, CreateTable / DeleteTable / UpdateTable are pure data-plane ops on the registry, no Firestore admin call.
Conditional writes
A conditional write reads the document and itsupdateTime, evaluates the DynamoDB condition in the adapter, then writes only if that version still matches. Creation uses a does-not-exist precondition.
A false condition returns ConditionalCheckFailed. A concurrent write changing the document version returns a retryable conflict. The SDK can retry the latter without treating it as a failed application condition.
This path adds an item read before the guarded write. The retry is driven by your SDK’s backoff, not looped inside the layer. Even without contention, the read adds latency and a billed read operation. On a hot, heavily-contended document the conflict-and-retry loop can spend more time colliding than making progress, and a finite SDK retry budget can surface that contention to your app as a throttling failure.
Which writes avoid reading the item first?
Firestore’s native API supports masked field updates and atomic transforms, including increments. The DynamoDB adapter currently uses a narrower set of these capabilities:
- PutItem and DeleteItem: one direct document write when there is no condition,
ReturnValues=NONEand DynamoDB Streams is disabled. - UpdateItem with only top-level REMOVE actions: one masked document write when there is no condition,
ReturnValues=NONE, Streams is disabled and the update does not touch the configured TTL attribute. - SET and other updates, by default: read-modify-write. A blind SET cannot validate the resulting DynamoDB item’s 400-KiB limit, so the adapter reads the existing item before applying and validating the update. Arithmetic, nested updates, returned item attributes and TTL-attribute changes also take this path; Firestore’s native increment support is not currently used for DynamoDB numeric updates.
FIRESTORE_ALLOW_OVERSIZED_BLIND_UPDATES to "true" in the adapter configuration to enable blind top-level SET/REMOVE updates under the same no-condition, no-return-values, no-Streams and no-TTL-change requirements. Primary-key and secondary-index-key changes, and updates on tables with an LSI, retain the validated path. This option defaults to "false".
The option skips only the aggregate 400-KiB resulting-item check. Firestore still enforces its 1-MiB document limit, which includes the adapter’s type tags and metadata; that is not a 1-MiB DynamoDB payload allowance. PutItem, read-modify-write and transactions retain their checks. An item grown above 400 KiB may need a blind removal or replacement of a large attribute before validated writes succeed. No performance measurement is claimed for this opt-in.
The read-modify-write path computes the new item in the adapter and writes with a document-version precondition, preserving atomicity when another writer races. Performance for a direct write does not establish the latency of this path. Native primitives are described in the Firestore Write API.
False conditions and concurrent document changes return different errors.
Secondary indexes
Each declared global secondary index is served off the base document. The compiler creates ascending and descending Firestore composite indexes over_tbl, the tagged hash scalar, the optional tagged range scalar, and the document’s stable id. There are no synthetic GSI fields and no separate projected data copy. A re-key updates the index with the item write, a delete disappears with the item, and a sparse item is absent because a missing hash or range field has no matching index entry.
Consistency is therefore stronger than DynamoDB, whose GSIs are eventually consistent: here an index query reflects the write that just committed. Hash/range predicates, ordering, cursor, limit, and supported filters are pushed into Firestore; an unrepresentable application filter is evaluated over that bounded native window before the declared DynamoDB attribute set is returned. Native numeric fields compare numerically, and the id tie-break makes pagination exact when several items share one GSI range value. The index must still be declared at compile time; an undeclared index fails with a validation error rather than falling back to a silent table scan.
A declared GSI is a Firestore composite index over the item’s native tagged scalar fields; the full query window is selected and ordered in storage.
Transactions
TransactWriteItems becomes a real Firestore read-write transaction. The layer opens a transaction, reads each targeted document inside it, evaluates each item’s DynamoDB condition in the adapter (Firestore’s document preconditions aren’t expressive enough), stages the writes, and commits them atomically. A condition that fails rolls the whole transaction back with nothing written; a commit that loses a race is a transient conflict the SDK retries.
Transactions can span documents, logical tables and partition keys. The adapter enforces DynamoDB’s 100-action limit. A cancelled transaction returns a single conditional-failure signal rather than per-item CancellationReasons, so the application cannot identify the failed condition from that response.
TransactWriteItems maps to a real Firestore transaction across documents: atomic, durable, and isolated, with no single-partition restriction; both the debit and credit commit, or neither does.
Limitations
△ Where DynamoDB and Firestore diverge, read before you adopt- Concurrent writes to one document. Repeated updates to the same document can encounter version conflicts or throttling. SDK retries add latency and billed operations, and a finite retry budget can surface an error to the application. Size and test the workload for its actual key distribution.
- Most updates need read-modify-write by default. SET reads the item to enforce the 400-KiB resulting-item limit; conditions, arithmetic, nested updates and returned attributes also need a read. Eligible REMOVE-only updates use a direct masked write. The explicit oversized-blind-update option extends this to eligible SET/REMOVE while relaxing item-size fidelity. A document-version conflict on read-modify-write returns a retryable error; SDK retries repeat the work and add latency and billed operations.
- Isolation is logical, not physical. Every table lives in the project’s one shared Firestore database, kept apart by the
_tbldiscriminator and the table-prefixed id and_pk, not by a separate database or a per-table identity. There is no per-install physical isolation (a dedicated named database, a database-scoped identity); the separation stays logical. - Secondary indexes must be declared when your stack is compiled. That means the
global_secondary_indexblocks on youraws_dynamodb_table, read when Tensor9 compiles your stack for release. It is not your application’s build, and not something it can do at runtime: adding an index means editing your Terraform and releasing again.CreateTablemay still name indexes, but they must match the ones already compiled in, andUpdateTablecannot introduce a new one. A GSI is served by Firestore composite indexes over native tagged item fields, which is synchronous and strongly consistent (stronger read visibility than DynamoDB’s eventually-consistent GSIs). The runtime cannot learn an index definition from traffic: querying one that was never declared returns a validation error rather than a silent table scan.ConsistentRead=trueis rejected on a global secondary index, matching DynamoDB’s GSI contract;ConsistentRead=falseis accepted. Local secondary indexes accept strongly consistent reads. The flag restriction does not change the synchronous physical index updates. - Reads are always strongly consistent. A single-document read reflects the latest committed write, regardless of
ConsistentRead. Estimate Firestore document and index-entry read charges for the actual queries. - Some filters run in the compute layer. Equality, bounded
IN, and compatible type predicates compile to native tagged item fields and run in Firestore. InFilterExpression,<>,attribute_not_exists,contains,begins_with,NOT, and predicates whose Firestore ordering would conflict with DynamoDB key order are evaluated by the service adapter over the bounded native key/range/cursor window. A stringbegins_withon a GSI sort key is still pushed down as a native access range. - A streamed table has one writing instance at a time. DynamoDB Streams are served from an ordered change-log document co-committed with each write in one transaction, which requires a single writer per streamed table: a second concurrent instance’s write is rejected as a retryable throttle until the writer lease passes. Run streamed-table writers at one replica; tables without a stream take concurrent writers safely, and reads are unaffected.
- Dropped-table storage needs cleanup.
DeleteTableinvalidates the current table generation with one metadata write. Old documents become invisible immediately but remain stored until bounded cleanup on a later deletion of the same table name. Large tables can therefore continue consuming storage after deletion. - Transaction cancellation is coarse. A cancelled
TransactWriteItemscurrently returns a single conditional-failure signal rather than DynamoDB’s per-itemCancellationReasons, so an app can’t yet tell which item’s condition failed.
Other considerations
- Data migration. The Firestore store starts empty. Move existing DynamoDB data through an export/import, backfill or dual-write procedure, and validate it before switching traffic.
- Operations and ownership. Google operates Firestore; Tensor9 operates the adapter. The adapter uses workload identity and short-lived credentials. The customer manages the Google Cloud project, cluster maintenance and monitoring.
- Capacity planning. Firestore bills operations and storage without provisioned throughput. Query and Scan select bounded native windows; unsupported application filters run over those windows in the adapter. Estimate charges from examined documents and index entries, and test contention on frequently updated documents.
- Read consistency. The request flag does not select an eventual-read path. Include strongly consistent reads when sizing the workload and estimating charges.
- Change streams. DynamoDB Streams are served from an ordered change-log document co-committed with each write, retained for a fixed window, and read back through the Streams API (all four view types). Ordering requires a single writer per streamed table; tables without a stream are unaffected.
Cloud Spanner
How it works
Your application keeps its AWS SDK, endpoint configuration, and DynamoDB item formats. The Tensor9 adapter accepts DynamoDB requests and stores data in Spanner. The adapter compiles aQuery’s key condition, filter, ordering, and limit into one GoogleSQL statement for Spanner to execute. Selective key ranges reduce work. Filters can still examine rows that are not returned, so compute consumption depends on more than the result size.
The application keeps calling the DynamoDB JSON API. The adapter translates it into GoogleSQL against Spanner.
Architecture
Spanner executes the SQL query, including its key condition, filter, ordering, and limit.
Rows and keys
Each DynamoDB table becomes a Spanner table. An item is one row, keyed first by its partition key and then by its sort key. This layout lets Spanner read a key range in order and applyScanIndexForward and pagination through the index.
Attribute values retain their DynamoDB types: decimal numbers preserve precision, binary remains binary, and strings and sets retain the distinctions the API requires.
The adapter encodes numeric values as sortable decimal text and normalizes sets. Spanner can evaluate supported comparisons in GoogleSQL over that representation without converting numbers to floating point.
The table also stores local index and expiry information. A local secondary index is interleaved with its table, so its entries are stored with the row they describe. Where a table declares a TTL attribute, the expiry is a generated column derived from that attribute, which a Spanner row-deletion policy uses, so expiry is physical deletion on Spanner’s schedule, not a hide-on-read filter the adapter applies.
Partition and sort keys form the Spanner primary key, which supports ordered range reads.
Transactions and consistency
TransactWriteItems uses a Spanner read-write transaction across tables and partition keys. Spanner’s external consistency also orders transactions in real time. The adapter enforces the 100-action limit, one operation per item and the ClientRequestToken idempotency window.
The adapter reads and locks items whose conditions or changes require their current values, evaluates the conditions, then commits the transaction. A failed condition and a concurrency conflict return distinct errors so the client can decide whether to retry. A requested failed-item image is returned with the condition error.
An unconditional Put can skip reading the item because it supplies the complete replacement. Conditions, returned prior values, updates computed from existing attributes and delete-size accounting require the existing item to be read.
DynamoDB’s aggregate limits are enforced as its own, not Spanner’s: each resulting item is validated against the 400 KiB item limit, and the transaction as a whole against DynamoDB’s 4 MiB aggregate item-data cap. A request that would exceed either fails the way it fails on AWS.
The adapter’s normal reads are strongly consistent, including requests with ConsistentRead=false. That flag does not select a cheaper or faster stale-read path. Strong reads on a global secondary index remain rejected for DynamoDB compatibility.
Transactions can span tables and partition keys. External consistency preserves transaction order in real time.
Secondary indexes
A global secondary index becomes a Spanner secondary index, and a local secondary index an index interleaved with its table. AQuery against either is an index seek, not a table scan.
Spanner commits secondary-index updates with the row. A subsequent strong index read includes that committed update. DynamoDB GSIs propagate asynchronously, so applications that tolerate index lag do not need to add that wait on this target.
Because the index is a real Spanner index rather than a projected copy, the query engine does the selection. The key condition, ordering, cursor and limit all lower into the index scan, so a selective index query reads close to what it returns rather than filtering a wider read afterwards. A sparse item, one missing the index’s key attribute, simply has no index entry, matching DynamoDB’s own sparse-index behaviour.
Two rules are inherited from the origin rather than from Spanner. You still cannot request a strongly-consistent read of a global secondary index: ConsistentRead=true against one is rejected, exactly as DynamoDB rejects it. And a query that asks for attributes the index does not project is rejected rather than silently fetching them from the base row, so an index with too few attributes for the query returns an error without fetching extra rows. The wire behaviour is the same as the origin’s, so code that handles those errors on AWS handles them here unchanged.
Spanner commits row and index updates together. A subsequent strong index read includes the update.
Limitations
△ Where DynamoDB and Spanner diverge, read before you adopt- Provisioned compute bills through quiet periods. Both services charge for stored data; Spanner also charges for provisioned compute. Steady utilization can amortize it, but savings depend on workload, capacity, region and replication topology.
- A hot item still contends. The adapter hashes partition routes to spread independent keys. Repeated updates to one item still compete; a uniform-key benchmark does not establish hot-item performance.
- A projected index cannot serve a full-item read.
Select=ALL_ATTRIBUTESagainst a global secondary index that does not project every attribute is rejected rather than quietly fetching the missing columns from the base table. The error names the index, so the fix is either to query the attributes the index already has, or to declare it so it includes them all. - Scan competes for provisioned compute. Paging limits response size, but a broad scan still reads the table and competes with point operations. Prefer a selective
Querywhen possible. - DynamoDB Streams are not served. The write executor rejects stream-enabled writes. Use another backend if the application requires an atomic DynamoDB change log.
- Some writes use read-modify-write. Conditions, responses that need the existing item, nested updates, arithmetic, changes to secondary-index keys and updates to tables with an LSI take this path. The adapter reads the item, evaluates the condition and computes the change, then commits it in the same Spanner transaction. A transaction conflict can repeat that work. Eligible top-level SET/REMOVE updates avoid the item read; this can include
UPDATED_NEWwhen the response is known from assigned values. - Measured latency depends on the request type. The 2026-08-31 workload measured simple unconditional updates. It does not measure the extra read or conflict retries of read-modify-write. The run used 1,000 processing units in us-west1, 100,000 records and a 50/50 read/update workload. With 16 worker threads, steady median throughput was 2,403.0 operations/s through Spanner versus 5,929.5 on native DynamoDB in us-west-2, using three 200,000-operation repetitions after warm-up. Spanner served strong reads; DynamoDB served eventual reads. The standalone adapter capture excluded Cloud Adapter deployment authorization and durable control-plane logging. These are complete-path results with different regions and consistency, not isolated adapter overhead or measurements of transactions and hot-item contention.
Other considerations
- Data migration. The Spanner database is provisioned empty. Existing DynamoDB items are not moved in place. Load them through the adapter’s DynamoDB API, validate the data and representative queries before switching application traffic. For an online migration, coordinate backfill and ongoing writes so changes made during the copy reach Spanner before cutover.
- Capacity planning. Size Spanner compute in processing units. Start from the read and write rates the DynamoDB table was provisioned for rather than from its storage size, and expect to tune once under real load. Spanner’s throughput per unit depends heavily on how well the primary key distributes.
- Operations and ownership. Google operates Spanner: replication, backups, and failover are theirs. Tensor9 operates the adapter. Spanner stores the data; the adapter runs beside the application.
- Authentication. The adapter authenticates to Spanner with the workload’s own identity through GKE Workload Identity. There is no key file and no secret to rotate.
Cloud Bigtable
How it works
Tensor9 runs a DynamoDB adapter beside your application in the customer’s environment. It configuresAWS_ENDPOINT_URL_DYNAMODB to send the AWS SDK’s requests to the adapter over the local loopback network. Your SDK and application code stay unchanged. The adapter accepts DynamoDB requests and data types, returns DynamoDB responses and errors, and stores data in Google Cloud Bigtable. It connects to Bigtable over pooled gRPC using the workload identity; no key file is required.
Bigtable provides atomic writes for one row, not across rows , and the adapter maps each DynamoDB item to exactly one row. The adapter uses row mutations and version guards for item writes. This mapping rejects multi-item transactions, secondary indexes, DynamoDB Streams and per-item TTL; the limitations below describe the missing behavior.
Your application uses the DynamoDB API through an adapter in the target environment.
Architecture
The adapter translates DynamoDB keys and operations into Bigtable row keys, row ranges and mutations. Ordered key ranges implement base-table queries. DynamoDB attribute filters run in the adapter over the selected rows. A conditional write reads the item, evaluates the DynamoDB condition and usesCheckAndMutateRow to commit only if the row version still matches. Another writer changing the row triggers a retry of the read and condition check.
The adapter first builds a storage-independent request plan, then converts it to a Bigtable-specific plan for execution.
One row per item
Every logical DynamoDB table lives in one provisioned Bigtable table, and each item is one row there. A metadata row records each logical schema and its generation, so the logical tables stay separated inside the shared physical one rather than colliding. The row key is what makes reads efficient. It is laid out as the logical table, then the partition route, then the collated sort key, then an item digest. A generation identifier separates each table incarnation, so a table dropped and recreated cannot read its predecessor’s rows. Because the sort key is collated into the key in DynamoDB’s own order, one DynamoDB partition becomes one contiguous Bigtable range, and aQuery over a key range is a range read in the right order rather than a filter over unrelated rows.
Item data lives in a raw column family. A second family is an INT64 SUM aggregate, which exists so an integral counter can be updated by Bigtable itself in a single round trip instead of being read, added to and written back. See the next section.
The row key is built so a DynamoDB partition is one contiguous Bigtable range, in the origin’s sort order.
Writes and atomicity
An unconditionalPutItem or DeleteItem, and a top-level SET/REMOVE UpdateItem, use a single MutateRow, with no read of the old item and one round trip. A single integral numeric ADD, or a SET computed from the attribute’s own current value, uses Bigtable’s INT64 SUM aggregate cell, so Bigtable performs the addition itself in one RPC rather than the adapter reading, adding and writing back.
Conditions, returned item values, nested changes and arithmetic beyond the supported counter case require a read. The adapter computes the change and commits it with CheckAndMutateRow against a version field. Conflicting writes repeat the read and condition check, up to eight attempts.
All of this is atomic for one item and linearizable because the configured app profile routes to a single cluster. Multi-cluster routing would break the row-transaction contract, so the adapter verifies the routing and the column-family contracts through the Bigtable Admin API at startup, before it serves any traffic, rather than discovering a misconfiguration under load.
A top-level SET/REMOVE that skips the item read cannot check the size of the item it produces, so that path relaxes DynamoDB’s aggregate 400 KiB limit. An application that depends on that limit being enforced exactly should use a backend that reads before writing.
Simple mutations and supported counters can avoid a read; other writes read the item and use a version guard.
Reads and filters
AQuery is a range read. Because the sort key is encoded in DynamoDB order in the row key, the key condition, the direction and the page boundary are all served by the row range itself, and results come back in DynamoDB’s order without the adapter re-sorting them.
For a FilterExpression, filters are evaluated in the adapter, not in storage. Bigtable’s own row filters work on families, qualifiers and versions rather than on DynamoDB’s typed attribute predicates, so the adapter reads the range and applies your filter to the items it read. As a result, a filtered read can read more rows than it returns, and a highly selective filter over a wide range costs what the range costs, not what the result costs. Prefer a selective key condition over a broad range plus a filter.
Scan pages through the table the same way. Paging bounds the response, but a broad scan still reads the rows it passes over and competes with point operations for the instance’s provisioned capacity.
Reads are strongly consistent through the single-cluster app profile. ConsistentRead=false does not select a cheaper or staler path. A request that would be eventually consistent on DynamoDB is simply served strongly here.
A filtered read examines every row in the selected key range. The adapter returns only rows that match the filter.
Limitations
The following features require operations across multiple rows or per-item deletion rules that this Bigtable layout cannot provide. The adapter rejects these requests.- Multi-item transactions.
TransactWriteItems,TransactGetItemsand transactional PartiQL are rejected: Bigtable cannot atomically commit or snapshot several item rows. An application that needs cross-item atomicity wants Spanner or a Postgres target. - Secondary indexes. A GSI or LSI query is not served. There is no maintained index to seek, so the adapter rejects these requests without scanning the table. Native Bigtable materialized views are not exposed as DynamoDB indexes either.
- DynamoDB Streams. Not served. Bigtable change streams are not a DynamoDB-compatible atomic change log. A stream record cannot be committed in the same atomic unit as the write it describes.
- Per-item TTL. Bigtable does expire data, but only through a column-family garbage-collection policy: a uniform max age or version count for the whole family. DynamoDB’s TTL is per item, driven by an epoch attribute the application sets on each row, and a family-wide rule cannot express that. Configuring TTL fails rather than silently applying a different expiry rule.
- The blind-write size check. A top-level SET/REMOVE that skips the read also skips the aggregate 400 KiB validation, as described above.
- Filters are adapter-side. A filtered read can read more rows than it returns; see the previous section.
- A hot item still contends. Row keys spread independent partitions, but repeated updates to one item serialize on that row. A uniform-key benchmark does not predict hot-item behaviour.
Cloud SQL for PostgreSQL
How it works
Tensor9 runs a DynamoDB adapter beside your application in the customer’s environment. It configuresAWS_ENDPOINT_URL_DYNAMODB to send the AWS SDK’s requests to the adapter over the local loopback network. Your SDK and application code stay unchanged. The adapter accepts DynamoDB requests and data types, returns DynamoDB responses and errors, and stores data in PostgreSQL.
DynamoDB supports numbers with up to 38 significant digits, typed sets, conditional writes, secondary indexes, and atomic transactions. The adapter implements these using PostgreSQL storage and transactions. PostgreSQL supports multi-table transactions and synchronously updated indexes, but has different concurrency, capacity, and identifier limits. Unsupported requests return errors; the limitations below describe the differences.
For base-table Query and Scan requests, the adapter compiles supported filters into parameterized SQL WHERE predicates over the JSONB item. Number comparisons cast stored decimal text to PostgreSQL numeric. An order-preserving key column supplies sort bounds, cursor position, and direction. The query uses a server-side cursor to fetch bounded batches until the requested Limit or 1 MiB of item data. Expressions that cannot be represented in SQL are evaluated in the adapter over the rows selected by the SQL predicate. Secondary-index queries use the separate path described below.
For conditional writes, the adapter opens a transaction, locks the row with SELECT ... FOR UPDATE, evaluates the ConditionExpression, and writes within that transaction. This requires an extra round trip, but prevents a concurrent writer from changing the item between the check and write. A failed condition returns ConditionalCheckFailed and can return the item that failed it. Some unconditional writes skip the read: a top-level SET/REMOVE patch or integral counter add uses one statement when it has no condition, requests no returned item image, and affects no secondary index or stream.
Your application uses the DynamoDB API through an adapter in the target environment.
Architecture
The adapter is a stateless Rust service. It parses DynamoDB expressions, translates requests into PostgreSQL operations, and returns DynamoDB responses. It connects to PostgreSQL through a connection pool using the customer’s credentials. Durable state stays in PostgreSQL, so adapter instances can restart or scale out without coordinating local state. Stream-enabled tables have the separate single-writer restriction below. Each DynamoDB table has a PostgreSQL table namedddb_<name> in one database. The ddb_tables registry records its generated physical name, key schema, billing mode, time-to-live (TTL) settings, global secondary indexes (GSIs), and stream settings. A write or query for a table absent from the registry returns ResourceNotFoundException; writes do not create tables implicitly. At runtime, CreateTable creates a PostgreSQL expression index for each declared GSI. Each stream-enabled table also has a _strm table that stores change records.
A stateless Rust adapter serves the DynamoDB API in front of Postgres; all durable state lives in one Postgres database, where each DynamoDB table is its own physical relation.
The adapter first builds a storage-independent request plan, then converts it to a Postgres-specific plan for execution.
Schemas
A DynamoDB item occupies one PostgreSQL row with columnspk TEXT, sk TEXT, okey TEXT and item JSONB. The PRIMARY KEY (pk, sk) index serves GetItem. A separate index over pk and bytewise-collated okey serves ordered base-table Query requests.
The item JSONB column preserves DynamoDB’s typed attribute representation, including {"N":"123.45"}, {"B":"<b64>"}, and typed sets. Numbers remain decimal strings, so a 38-digit amount or a Snowflake ID is not rounded through a floating-point conversion. Type tags preserve the distinction between numbers and numeric strings. Conditional writes use row locks, not a separate version column. Enabling TTL builds an expression index over the configured epoch attribute inside the JSONB item. A bounded, leader-elected reaper deletes eligible rows through that index. Expired items remain readable until physical deletion; reads do not filter on TTL.
Each attribute keeps its DynamoDB type tag inside item JSONB , so {“N”:“123.45”} keeps its exact decimal string.
Where tables live
Each DynamoDB table has its own PostgreSQL table, indexes and optional_strm change-record table. CreateTable creates those database objects and their registry entry; DeleteTable drops them. Storage, indexes and vacuum settings can be tuned per table.
The adapter checks a 500-table guardrail per namespace when creating a physical DynamoDB table. Concurrent creates of different tables can exceed that guardrail; it is not a hard PostgreSQL quota. Plan database capacity and table placement for larger deployments. Provisioned instances continue to incur compute costs while idle.
Each declared table is its own physical relation. CreateTable and DeleteTable create and drop PostgreSQL relations. The adapter applies a 500-table guardrail per namespace.
Conditional writes
To evaluate a conditional write, the adapter locks the item’s(pk, sk) row with SELECT … FOR UPDATE, checks the ConditionExpression against that row, then writes and commits. The lock prevents another writer from changing the item between the check and write.
A false condition returns the non-retryable ConditionalCheckFailed error. A PostgreSQL serialization failure or lock timeout (40001/lock-not-available) instead returns a retryable throughput error, allowing the SDK to back off and retry. Concurrency failures are not reported as failed conditions.
For attribute_not_exists(pk), the PostgreSQL primary-key constraint prevents two concurrent requests from creating the same item. Only one insert can succeed for a given (pk, sk).
Failed conditions and PostgreSQL concurrency conflicts return different error types.
Secondary indexes
On each write, the adapter adds two index fields to the item’s JSONB. The partition-key field,_idx_<name>_h, normalizes equivalent values such as 1 and 1.0. The sort-key field, _idx_<name>_o, preserves sort order and uses the base key to break ties. A PostgreSQL expression index over these fields locates matching partition keys. The adapter then evaluates sort-key conditions, ordering, pagination, and any FilterExpression over the retrieved items.
The index fields and item commit in the same transaction, so GSI queries are strongly consistent. DynamoDB’s own GSIs update asynchronously and are eventually consistent. Each index lookup is limited to 10,000 candidate items for one index partition-key value. Exceeding this limit returns an error; choose index keys with this limit in mind.
A GSI uses a PostgreSQL expression index to find candidates, then the adapter filters and orders them. Index updates commit with the item, making GSI queries strongly consistent.
Transactions
TransactWriteItems executes in one PostgreSQL BEGIN…COMMIT transaction at SERIALIZABLE isolation. A debit in one table and a credit in another either both commit or both fail, as in DynamoDB.
Transactions enforce DynamoDB’s 100-action limit and one-operation-per-item rule. The adapter records ClientRequestToken in the same transaction as the writes. Within DynamoDB’s idempotency window, a retry with the same token returns the prior success without committing the writes again. The adapter acquires row locks in a consistent order to prevent opposite-direction transfers from deadlocking. Serialization or deadlock failures return retryable conflicts.
TransactWriteItems is not supported on stream-enabled tables. A request that touches any such table returns ValidationException before execution, because the transaction path does not create the required stream records.
TransactWriteItems runs in one native Postgres BEGIN…COMMIT at SERIALIZABLE, atomic and isolated across tables.
Limitations
PostgreSQL limitations- One PostgreSQL instance serves each table. Heavy contention on one key can cause serialization failures and row-lock conflicts. The adapter returns retryable errors, but sustained contention can exhaust the SDK retry budget and appear as throttling.
- Stream-enabled tables allow one writer at a time. An owner lease of about 90 seconds preserves stream record order. A second adapter instance attempting to write while that lease is active receives a retryable
ThrottlingException. Throughput depends on the writer, database and request workload. Tables without streams support concurrent writers. DescribeStreamlists at most 100 shards. A stream with more shards is not fully enumerated in one call.- Transactions on stream-enabled tables are unsupported. A
TransactWriteItemsrequest touching one returnsValidationExceptionbecause the transaction path does not create stream records. - DynamoDB backup and global-table APIs are unsupported. Use PostgreSQL backups, snapshots, point-in-time recovery (PITR), and replica/failover procedures. DynamoDB on-demand backup/restore, PITR, and global-table replication APIs are not implemented.
- Instances incur costs when idle. Size and pay for PostgreSQL capacity ahead of demand. Larger table counts require more instances and table-placement planning.
- Index lookups have a 10,000-item limit. A lookup that exceeds 10,000 candidate items for one index partition-key value returns an error.
- Plan for the table-count guardrail. The adapter checks 500 physical DynamoDB tables per namespace. Larger deployments need database capacity and table-placement planning.
- Expired items remain readable until deletion. A bounded, leader-elected reaper uses the TTL expression index to delete eligible rows asynchronously. Until physical deletion, GetItem, Query and Scan can return the expired item, and it continues to consume storage.
Other considerations
- Stream records commit with the write. The adapter stores each record in the table’s
_strmtable within the same transaction as the item write. A record exists if and only if its write committed. The single-writer lease keeps stream sequence order aligned with commit order so a cursor can resume without skipping records. - Use PostgreSQL recovery procedures. Backups, PITR, snapshots, and replica/failover procedures operate on the PostgreSQL database. The adapter does not implement DynamoDB backup, PITR, or global-table APIs.
- Connection pooling. The adapter uses pooled PostgreSQL connections. Deployments spread across many instances may need a separate pooler to keep total connections within each database’s limit.
- Migrate and test one table at a time. Separate PostgreSQL tables allow per-table export/import, dual-write, or backfill-and-switch procedures. Application code continues to use the DynamoDB API.
- Use PostgreSQL operational tools. Monitor SQL activity, maintain indexes, vacuum tables, and run backups with your existing PostgreSQL tools and procedures.