A DynamoDB emulator backed by SQLite. Runs as an HTTP server, an MCP server for coding agents, or embeds directly into Rust and iOS applications as a library.
I built Dynoxide because DynamoDB Local is slow, heavy, and can't embed. It needs a JVM, and the typical Docker-based setups adds 2–3 seconds of cold-start, ~188 MB of memory at idle, and a ~225MB Docker image (~471 MB on disk) before you've done anything useful. If you're running integration tests, that's Docker starting, the JVM warming up, and your pipeline waiting.
Dynoxide is a native binary. It starts in milliseconds, idles at ~4.9 MB, and ships as a ~3 MB download. Point any DynamoDB SDK at it and your tests just work.
For Rust projects, there's also an embedded mode - direct API calls via Database::memory() with no HTTP layer at all. Each test gets an isolated in-memory database with zero startup cost. And because it compiles to a native library with no runtime dependencies, it runs on platforms where DynamoDB Local can't, including iOS.
| Metric | Dynoxide (embedded) | Dynoxide (HTTP) | DynamoDB Local |
|---|---|---|---|
| Cold startup | ~0.2ms | ~15ms | ~2,287ms |
| GetItem (p50) | 9µs | 0.1ms | 0.8ms |
| PutItem throughput | ~51,613 ops/s | ~6,703 ops/s | ~945 ops/s |
| 50-test suite (sequential) | ~484ms | ~569ms | ~2,407ms |
| 50-test suite (4x parallel) | ~203ms | ~235ms | ~1,189ms |
Numbers from ubuntu-latest (2-core AMD EPYC 7763, 8GB RAM). Commit f5052db.
| Metric | Dynoxide (embedded) | Dynoxide (HTTP) | DynamoDB Local | LocalStack (all services) |
|---|---|---|---|---|
| Cold startup | <1ms | ~2ms | ~2,769ms | ~8,627ms |
| GetItem (p50) | 14µs | 0.3ms | 0.8ms | — |
| 50-test CI suite | 722ms | 731ms | 2,265ms | — |
| Full workload (10K items) | — | 2.9s | 10.8s | — |
| Binary / image (download) | ~3 MB | ~3 MB | 225 MB | 1.1 GB |
| Binary / image (on disk) | 6 MB | 6 MB | 471 MB | 1.1 GB |
| Idle memory (RSS) | ~4.9 MB | ~8 MB | ~188 MB | ~358 MB |
The gap is wider on Apple Silicon because the faster CPU amplifies the difference between native code and JVM overhead. Both are real measurements of the same benchmark suite. Full methodology and per-operation breakdowns →
Dynoxide is continuously verified against real DynamoDB by the dynamodb-conformance suite, which runs one test matrix against AWS itself and every major DynamoDB emulator. Pass rates move as the suite grows and each engine changes, so rather than pin a snapshot that goes stale, see the live standings:
- paritysuite.org: current pass rates for every engine, broken down by tier
- nubo-db/dynamodb-conformance: the suite itself, the raw results, and how each target is run
This covers the native build. The WebAssembly build is a preview and isn't run against the suite yet.
| Dynoxide | DynamoDB Local | LocalStack (all services) | dynalite | |
|---|---|---|---|---|
| Language | Rust | Java | Python + Java | Node.js |
| Storage | SQLite | SQLite | SQLite (via DDB Local) | LevelDB |
| Runtime dependency | — | JVM | Docker + LocalStack | Node.js |
| Embeddable (Rust / iOS) | ✓ | — | — | — |
| MCP server for agents | ✓ | — | — | — |
LocalStack uses DynamoDB Local internally as its DynamoDB engine, so its startup and memory overhead includes DynamoDB Local's JVM plus LocalStack's own Python routing layer.
npm install --save-dev dynoxideOr run directly without installing:
npx dynoxide --port 8000brew install nubo-db/tap/dynoxideDownload from GitHub Releases for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows.
# Example: Linux x86_64
curl -fsSL https://github.com/nubo-db/dynoxide/releases/latest/download/dynoxide-x86_64-unknown-linux-musl.tar.gz | tar xz
sudo mv dynoxide /usr/local/bin/cargo install dynoxide-rs
# With encryption support (SQLCipher + vendored OpenSSL)
cargo install dynoxide-rs --no-default-features --features encrypted-full[dependencies]
# Minimal - just the embedded database, no server or CLI dependencies
dynoxide-rs = { version = "0.10", default-features = false, features = ["native-sqlite"] }
# Or with encryption:
# dynoxide-rs = { version = "0.10", default-features = false, features = ["encryption"] }0.10.0 is a breaking release, but most of the breaks are library-only. The CHANGELOG has the full list.
Running the binary (Homebrew, npm, the release archives, or the Docker image)? One change affects you:
- MCP over HTTP now requires a bearer token. Existing HTTP-transport clients break until they send an
Authorization: Bearer <token>header. A loopback bind generates and persists a token on first run; a non-loopback bind will not start without one (--mcp-tokenorDYNOXIDE_MCP_AUTH_TOKEN). The stdio transport is unaffected, and plaindynoxide serve(DynamoDB only, no MCP) is unchanged. See MCP Server.
Depending on the dynoxide-rs crate? Also note:
DynoxideErroris now#[non_exhaustive]. Code that matches it exhaustively needs a_ =>arm.Databaseis now generic,Database<S>. The parameter defaults to the native backend, so code that namesDatabasekeeps compiling; a newNativeDatabasealias names that default explicitly.- Embedding the MCP HTTP server:
dynoxide::mcp::serve_httpandserve_http_with_shutdowntake anHttpOptionsstruct (bind host, auth mode, allowed hosts) in place of a bare port.
- uses: nubo-db/dynoxide/action@v0.10.0
with:
snapshot-url: https://example.com/test-data.db.zst # optional
port: 8000See action/action.yml for all inputs and outputs.
A 5 MB drop-in for amazon/dynamodb-local in containerised test suites. Same DynamoDB-compatible API, faster startup, smaller image. Note that this is a packaging convenience for test fixtures, not a containerised database product; production-database-on-Kubernetes patterns are out of scope.
docker run --rm -p 8000:8000 ghcr.io/nubo-db/dynoxideWith persistent storage:
docker run --rm -p 8000:8000 \
-v "$(pwd)/data:/data" \
ghcr.io/nubo-db/dynoxide \
serve --host 0.0.0.0 --port 8000 --db-path /data/dynoxide.sqliteThe image runs as root by default, matching amazon/dynamodb-local, so bind mounts on Linux Just Work without --user. The canonical image lives at ghcr.io/nubo-db/dynoxide. Mirrors are pushed to docker.io/nubodb/dynoxide and public.ecr.aws/h4s0n6a2/dynoxide on a best-effort basis. SLSA provenance and SBOM attestations are published to GHCR only; if you want to verify provenance, pull from the GHCR canonical.
If you override CMD to bind to a different port, set the healthcheck target with environment variables so the container's HEALTHCHECK follows:
docker run -e DYNOXIDE_HEALTHCHECK_PORT=9000 ghcr.io/nubo-db/dynoxide serve --port 9000DYNOXIDE_HEALTHCHECK_HOST and DYNOXIDE_HEALTHCHECK_PORT are documented public surface and will not be renamed in a patch or minor release.
For security-conscious operators, opt into a nonroot uid:
docker run --rm -p 8000:8000 --user 65532:65532 ghcr.io/nubo-db/dynoxidePersistent mode under nonroot needs a host-owned bind mount, since the in-image /data is owned by root:
docker run --rm -p 8000:8000 \
--user "$(id -u):$(id -g)" \
-v "$(pwd)/data:/data" \
ghcr.io/nubo-db/dynoxide \
serve --host 0.0.0.0 --port 8000 --db-path /data/dynoxide.sqliteThe default in-memory mode needs no flags whether root or nonroot. The uid 65532 is the well-known nonroot uid used by Google's distroless images; pick any uid you prefer with --user <uid>:<gid>.
The default image serves DynamoDB only. To also expose the MCP Streamable HTTP transport, override the command to start it on 0.0.0.0 and supply a bearer token. The token is mandatory for any non-loopback bind. Pass it via the DYNOXIDE_MCP_AUTH_TOKEN environment variable (which keeps it out of shell history and ps), not a --mcp-token flag:
TOKEN=$(openssl rand -base64 24)
docker run --rm -p 8000:8000 -p 19280:19280 \
-e DYNOXIDE_MCP_AUTH_TOKEN="$TOKEN" \
ghcr.io/nubo-db/dynoxide \
serve --host 0.0.0.0 --port 8000 \
--mcp --mcp-host 0.0.0.0 --mcp-port 19280DynamoDB is then reachable on http://localhost:8000 and MCP on http://localhost:19280/mcp. Point an HTTP-transport MCP client at the latter with an Authorization: Bearer <token> header. See MCP Server for the client config shape.
A few things to know:
- The token is not optional. Omit it and the container exits immediately with
a non-loopback MCP bind requires an explicit token. The defaultdocker run ghcr.io/nubo-db/dynoxidestays DynamoDB-only precisely because a token-less0.0.0.0MCP bind cannot boot. - Reaching MCP from another container by service name (rather than
localhost) needs that name added to the Host allowlist:--mcp-allowed-host <name>(e.g.--mcp-allowed-host dynoxide). The-p-mappedlocalhostaccess above needs nothing extra. --network host(Linux only) is an alternative to-p, but it bypasses Docker network isolation and binds MCP directly on the host's network interface, reachable from the LAN, not just the host. Prefer-punless you specifically need host networking.
Dynoxide compiles to wasm32-unknown-unknown and runs in the browser. The same engine that backs the native build runs against wa-sqlite - a WASM build of SQLite - over a wasm-bindgen bridge, with the database persisted to OPFS (the origin private file system).
Both backends issue the same SQL. The native and wasm code share one set of query builders, so a query fixed on one is fixed on both.
It's a preview. The wasm build is not run against the conformance suite that backs the native build, so its correctness rests on its own tests for now. A build made with --features wasm-sqlite exposes dynoxide::WASM_PREVIEW (true) so you can tell which path you're on.
What works: create and delete tables, describe and list them, put, get, delete, and update items, query, scan, and the batch and transactional reads (BatchGetItem, BatchWriteItem, TransactGetItems), over base tables and both secondary index types (GSI and LSI). Index maintenance is atomic with the base write, same as native.
What doesn't, yet: TTL returns a typed Unsupported error (it needs a background sweep the browser doesn't drive). Streams are planned but not wired - the delivery mechanism is still to be decided. TransactWriteItems, tags, table-setting updates, table stats, and bulk import return a preview "not yet implemented" error.
The engine runs in a Web Worker (OPFS's synchronous file handles are Worker-only), and the page talks to it over a message channel. It needs no special server headers (no COOP/COEP cross-origin isolation), so it works on ordinary static hosting.
npm install then npm run build:wasm produces a self-contained dist/ (use build:wasm:dev to skip wasm-opt for speed):
npm install
npm run build:wasmdist/ is the two .wasm plus the bundled Worker, kept separate so the .wasm cache independently of the JS bundle, and a small manifest:
| File | Size | What |
|---|---|---|
dynoxide_bg.wasm |
~960 KB | the engine (release, wasm-opt) |
wa-sqlite.wasm |
~545 KB | SQLite (the synchronous build) |
dynoxide-worker.js |
~130 KB | the bundled Web Worker (wa-sqlite glue + bridge) |
manifest.json |
<1 KB | engine version, contract version, file list |
About 1.6 MB total. Not tiny, but the .wasm files are immutable and cache well, and using wa-sqlite's synchronous build keeps it off the larger Asyncify async build.
Drop dist/ on any origin that's a secure context - HTTPS in production, or localhost for development. OPFS needs a secure context, but no COOP/COEP headers and no cross-origin isolation, so plain static hosting works. (SQLite in the browser usually needs cross-origin isolation, because the common technique makes an async storage API look synchronous via SharedArrayBuffer. Dynoxide avoids that by running wa-sqlite's synchronous OPFS VFS inside a Worker, where synchronous file handles are available directly.) One header does matter: if you set a Content-Security-Policy it must allow 'wasm-unsafe-eval', or the engine won't instantiate. Serve the .wasm as application/wasm while you're at it.
Spawn the bundle as a module Worker and drive it over postMessage; the two .wasm files must sit next to dynoxide-worker.js, which is where the build puts them. The Worker speaks one coarse RPC: a message in, a reply out, correlated by an id you supply.
in: { id, op, payload, contractVersion? }
out: { id, ok: true, result } // result is a JSON string
{ id, ok: false, error } // error is a JSON string
Three ops carry the engine:
open-payload: { name }opens (or reopens) the OPFS-backed database and resolves with the contract descriptor,{ contractVersion, capabilities }. Call it once before any operation.execute-payload: { op, request }runs one DynamoDB operation, whereopis the operation name (PutItem,Query,Scan, ...) andrequestis a plain DynamoDB-JSON object. It resolves with the response JSON and rejects with an error envelope (the same__type/messageshape the native HTTP server speaks). Askcapabilitiesfor the supported set rather than guessing; anything outside it comes back as anUnsupportedOperationenvelope.capabilitiesandcontractVersion- the supported op list and the engine's contract version, for a client that wants them without opening a database.
contractVersion stamps the envelope shape, not the engine version. Adding an op is additive and leaves it alone; changing the request, response, or error envelope bumps it. Stamp your messages with the version you built against and the Worker rejects a mismatch loudly, so a stale embed fails with a clear error instead of mis-parsing a newer engine. The shipped version sits in manifest.json and is what open echoes back.
The harness under harness/ is a working example, and it loads the same bundled Worker a production consumer would:
npm run build:wasm
python3 -m http.server 8081
# then open http://localhost:8081/harness/It opens the engine, creates a table, writes a few rows, then runs a query and a filtered scan against the OPFS-backed database so you can see ScannedCount come back higher than Count. Because it drives the shipping bundle rather than a parallel build, a green harness means the shipping artefact works. (The older smoke ops live behind npm run build:wasm:harness, which adds them on top of the same Worker.)
Rather than build the engine yourself, you can depend on the same artefacts as an npm package, @nubo-db/dynoxide-engine. npm run build:wasm assembles it under npm/dynoxide-engine/ - the Worker, the two .wasm, the manifest, and an EngineClient that owns the RPC above so you deal in objects, not postMessage envelopes:
import { EngineClient } from "@nubo-db/dynoxide-engine";
const client = new EngineClient(); // resolves the Worker beside the package
await client.ready();
await client.execute("CreateTable", { /* ... */ });
const { Items } = await client.execute("Query", { /* ... */ });new EngineClient() with no arguments resolves the Worker next to the package, and the Worker resolves the .wasm next to itself, so a bundler that copies the package's files - or a plain static deploy of them - needs no configuration. Serving the assets from a CDN or another origin? Pass assetBase (the directory they sit in) or workerUrl (the exact Worker URL).
The package also exports EngineError (the typed rejection, carrying the engine's __type on .type) and CONTRACT_VERSION. The client checks that version against the engine on boot and fails loudly on a mismatch, so a pinned consumer never mis-reads a newer engine. Hosting matches dist/: a secure context, no COOP/COEP, a CSP that allows 'wasm-unsafe-eval', and .wasm served as application/wasm. It's a preview, like the rest of the wasm build.
Start the server:
dynoxide --port 8000With a persistent database:
dynoxide --db-path data.db --port 8000With encryption (requires the encrypted-server build):
# Generate a key
openssl rand -hex 32 > key.hex
chmod 600 key.hex
# Start with key file
dynoxide --db-path data.db --encryption-key-file key.hex
# Or via environment variable
DYNOXIDE_ENCRYPTION_KEY=$(cat key.hex) dynoxide --db-path data.dbThen use the AWS CLI or any DynamoDB SDK pointed at localhost:
aws dynamodb list-tables --endpoint-url http://localhost:8000
aws dynamodb put-item \
--endpoint-url http://localhost:8000 \
--table-name Users \
--item '{"pk": {"S": "user#1"}, "name": {"S": "Alice"}}'
aws dynamodb get-item \
--endpoint-url http://localhost:8000 \
--table-name Users \
--key '{"pk": {"S": "user#1"}}'Works with any language or SDK that supports custom endpoints: Python (boto3), Node.js (AWS SDK v3), Go, Java, etc.
Dynoxide includes an MCP server that exposes DynamoDB operations as tools for coding agents (Claude Code, Cursor, etc.).
dynoxide mcp
dynoxide mcp --db-path data.dbdynoxide mcp --http --port 19280The HTTP transport requires a bearer token on every request. On a loopback
bind with no token supplied, dynoxide generates one on first run, saves it to a
per-user config file (~/.config/dynoxide/mcp-token on Linux,
~/Library/Application Support/dynoxide/mcp-token on macOS), and prints a
ready-to-paste client snippet; later runs reuse it silently. Supply your own
with --token or the DYNOXIDE_MCP_AUTH_TOKEN environment variable (the flag
wins if both are set).
| Flag | Purpose |
|---|---|
--host <HOST> |
Bind address (default 127.0.0.1). Non-loopback binds require an explicit token. |
--token <TOKEN> / DYNOXIDE_MCP_AUTH_TOKEN |
Use a fixed token instead of the persisted one. |
--allowed-host <HOST> |
Accept an additional Host header by name (repeatable); needed for non-loopback access by hostname. |
--no-auth |
Disable authentication. Loopback binds only; prints a warning. |
Prefer the environment variable or the persisted file over --token for
anything beyond one-shot debugging, because flag values leak into shell history and
ps. To rotate the token, delete the persisted file (or change
DYNOXIDE_MCP_AUTH_TOKEN) and restart; there is no rotation mechanism by
design.
On the serve subcommand the equivalent flags are prefixed
(--mcp-host, --mcp-token, --mcp-no-auth, --mcp-allowed-host) because
serve already owns --host/--port for the DynamoDB server.
To run the HTTP transport from the container image, see MCP over HTTP in Docker.
Point an HTTP-transport MCP client at the endpoint and send the token in an
Authorization header:
{
"mcpServers": {
"dynoxide": {
"type": "http",
"url": "http://127.0.0.1:19280/mcp",
"headers": { "Authorization": "Bearer <TOKEN>" }
}
}
}Add to your mcp.json:
{
"mcpServers": {
"dynoxide": {
"command": "dynoxide",
"args": ["mcp"]
}
}
}Or with a persistent database:
{
"mcpServers": {
"dynoxide": {
"command": "dynoxide",
"args": ["mcp", "--db-path", "dev.db"]
}
}
}With a OneTable data model for single-table designs:
{
"mcpServers": {
"dynoxide": {
"command": "dynoxide",
"args": ["mcp", "--db-path", "dev.db", "--data-model", "onetable.json"]
}
}
}| Category | Tools |
|---|---|
| Tables | list_tables, describe_table, create_table, delete_table, update_table |
| Items | get_item, put_item, update_item, delete_item |
| Batch | batch_get_item, batch_write_item, bulk_put_items |
| Query | query, scan |
| Transactions | transact_get_items, transact_write_items |
| PartiQL | execute_partiql, batch_execute_partiql, execute_transaction_partiql |
| TTL | update_time_to_live, describe_time_to_live, sweep_ttl |
| Tags | tag_resource, untag_resource, list_tags_of_resource |
| Streams | list_streams, describe_stream, get_shard_iterator, get_records |
| Snapshots | create_snapshot, restore_snapshot, list_snapshots, delete_snapshot |
| Info | get_database_info |
# Read-only mode - rejects all write operations
dynoxide mcp --read-only --db-path prod-snapshot.db
# Limit query/scan results
dynoxide mcp --max-items 100 --max-size-bytes 65536The MCP server supports database snapshots for safe experimentation:
create_snapshot- saves a point-in-time copy of the databaserestore_snapshot- rolls back to a previous snapshotlist_snapshots- lists available snapshots- Auto-snapshot before
delete_table(last 10 kept automatically)
For single-table designs, raw DynamoDB metadata (pk is type S, GSI1 exists) tells an agent almost nothing. The --data-model flag loads a OneTable schema so the agent sees entity names, key templates, GSI mappings, and type discriminator attributes.
dynoxide mcp --data-model schema.json
dynoxide mcp --data-model schema.json --db-path data.dbThe data model is context-only - dynoxide does not validate writes against the schema. See docs/mcp-data-model.md for the full format reference, options, and examples.
Dynoxide supports DynamoDB Streams with all four view types: NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES, and KEYS_ONLY.
Streams are enabled per-table via StreamSpecification in CreateTable or UpdateTable, exactly like real DynamoDB:
# Via AWS CLI
aws dynamodb create-table \
--endpoint-url http://localhost:8000 \
--table-name Events \
--key-schema AttributeName=pk,KeyType=HASH \
--attribute-definitions AttributeName=pk,AttributeType=S \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
# Enable on an existing table
aws dynamodb update-table \
--endpoint-url http://localhost:8000 \
--table-name Events \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGESVia the MCP server, pass stream_specification to create_table or update_table.
# List streams
aws dynamodbstreams list-streams --endpoint-url http://localhost:8000
# Describe a stream to get shard IDs
aws dynamodbstreams describe-stream \
--endpoint-url http://localhost:8000 \
--stream-arn arn:aws:dynamodb:local:000000000000:table/Events/stream/...
# Get a shard iterator and read records
aws dynamodbstreams get-shard-iterator \
--endpoint-url http://localhost:8000 \
--stream-arn <stream-arn> \
--shard-id <shard-id> \
--shard-iterator-type TRIM_HORIZONIf the --schema file (DescribeTable JSON) contains a StreamSpecification, streams are automatically enabled on the imported table. No extra flags needed. The import faithfully reproduces the source table's configuration:
{
"Table": {
"TableName": "Events",
"StreamSpecification": {
"StreamEnabled": true,
"StreamViewType": "NEW_AND_OLD_IMAGES"
}
}
}Note: Imported items do not generate stream records by default (bulk import bypasses stream recording for performance). Stream recording begins for writes made after import completes.
Import data from DynamoDB Export (JSON Lines format) into a Dynoxide database, with optional anonymisation.
dynoxide import \
--source ./export-data/ \
--schema schema.json \
--output snapshot.dbThe --source directory should follow DynamoDB Export structure:
export-data/
├── Users/
│ └── data/
│ └── 00000000.json.gz
└── Orders/
└── data/
└── 00000000.json.gz
The --schema file contains DescribeTable JSON (the output of aws dynamodb describe-table):
aws dynamodb describe-table --table-name Users > schema.jsondynoxide import --source ./export/ --schema schema.json --output snapshot.db \
--tables Users,OrdersCreate a rules file (rules.toml):
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "fake", generator = "safe_email" }
[[rules]]
match = "attribute_exists(phone)"
path = "phone"
action = { type = "mask", keep_last = 4, mask_char = "*" }
[[rules]]
match = "attribute_exists(ssn)"
path = "ssn"
action = { type = "hash", salt_env = "ANON_SALT" }
[[rules]]
match = "attribute_exists(notes)"
path = "notes"
action = { type = "redact" }
[consistency]
fields = ["userId", "email"]ANON_SALT=my-secret-salt dynoxide import \
--source ./export/ \
--schema schema.json \
--rules rules.toml \
--output anonymised.dbAction types:
| Action | Description |
|---|---|
fake |
Replace with generated data (safe_email, name, phone_number, address, company_name, sentence, word, first_name, last_name) |
mask |
Keep last N characters, mask the rest (keep_last, mask_char) |
hash |
SHA-256 hash with salt from env var (salt_env, required) |
redact |
Replace with [REDACTED] |
null |
Replace with NULL |
Consistency: Fields listed in [consistency].fields produce the same anonymised value across all tables in a single import run. Same input + same salt = same output.
# Overwrite an existing output file
dynoxide import --source ./export/ --schema schema.json --output snapshot.db --force
# Continue importing when a batch fails instead of aborting
dynoxide import --source ./export/ --schema schema.json --output snapshot.db --continue-on-error
# Compress output with zstd
dynoxide import --source ./export/ --schema schema.json --output snapshot.db --compress
# Produces snapshot.db.zstuse dynoxide::Database;
// In-memory (for tests)
let db = Database::memory().unwrap();
// Persistent (backed by SQLite file)
let db = Database::new("data.db").unwrap();
// Encrypted (requires `encryption` feature)
// cargo add dynoxide-rs --features encryption
let db = Database::new_encrypted("data.db", "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap();Operations use DynamoDB-compatible request/response types:
use dynoxide::Database;
use serde_json::json;
let db = Database::memory().unwrap();
// Create a table
let req = serde_json::from_value(json!({
"TableName": "Users",
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}]
})).unwrap();
db.create_table(req).unwrap();
// Put an item
let req = serde_json::from_value(json!({
"TableName": "Users",
"Item": {"pk": {"S": "user#1"}, "name": {"S": "Alice"}}
})).unwrap();
db.put_item(req).unwrap();
// Query
let req = serde_json::from_value(json!({
"TableName": "Users",
"KeyConditionExpression": "pk = :pk",
"ExpressionAttributeValues": {":pk": {"S": "user#1"}}
})).unwrap();
let resp = db.query(req).unwrap();Each test gets a fully isolated database with no shared state:
#[test]
fn test_user_creation() {
let db = Database::memory().unwrap();
// Set up table
db.create_table(/* ... */).unwrap();
// Test your logic
db.put_item(/* ... */).unwrap();
let result = db.get_item(/* ... */).unwrap();
assert!(result.item.is_some());
// db is dropped automatically - nothing to clean up
}No Docker. No port conflicts. No table name prefixes. Tests run in parallel without coordination.
| Flag | Default | Description |
|---|---|---|
native-sqlite |
Yes | Bundles plain SQLite. No OpenSSL. |
http-server |
Yes | Adds axum-based HTTP server exposing the DynamoDB JSON API. |
mcp-server |
Yes | Adds MCP server for coding agents (stdio and Streamable HTTP transports). |
import |
Yes | Adds dynoxide import CLI for importing DynamoDB Export data with anonymisation. |
cli |
Indirect | Gates the dynoxide binary. Pulled in automatically by http-server, mcp-server, or import, so default builds include it; a library-only or wasm-sqlite build omits the binary. |
wasm-sqlite |
No | wasm32 browser backend (wa-sqlite over OPFS), a preview. Pulls neither native SQLite nor the CLI. See the WASM section. |
encryption |
No | Bundles SQLCipher + vendored OpenSSL. Adds Database::new_encrypted() for encryption at rest. |
encryption-cc |
No | Like encryption but uses Apple CommonCrypto instead of bundled OpenSSL. For macOS and iOS builds. |
encrypted-server |
No | Convenience: enables encryption + http-server. |
encrypted-server-cc |
No | Convenience: enables encryption-cc + http-server. |
encrypted-full |
No | Convenience: enables encryption + http-server + mcp-server + import. |
full |
— | Alias for default features (backward compatibility). |
native-sqlite and encryption are mutually exclusive - they select different SQLite backends. To use encryption:
dynoxide-rs = { version = "0.10", default-features = false, features = ["encryption"] }Workspace note: Cargo unifies features across a workspace. If any crate depends on dynoxide-rs with default features (getting native-sqlite) and another uses encryption, both activate and the build fails. Use default-features = false on all dynoxide-rs dependencies in the workspace.
| Category | Operations |
|---|---|
| Table | CreateTable, DeleteTable, DescribeTable, ListTables, UpdateTable |
| Item | PutItem, GetItem, DeleteItem, UpdateItem |
| Query & Scan | Query, Scan |
| Batch | BatchGetItem, BatchWriteItem |
| Transactions | TransactWriteItems, TransactGetItems |
| PartiQL | ExecuteStatement, BatchExecuteStatement, ExecuteTransaction |
| Streams | DescribeStream, GetShardIterator, GetRecords, ListStreams |
| TTL | UpdateTimeToLive, DescribeTimeToLive |
| Tags | TagResource, UntagResource, ListTagsOfResource |
- KeyConditionExpression
- FilterExpression
- ConditionExpression (attribute_exists, attribute_not_exists, begins_with, contains, size, between, in)
- ProjectionExpression
- UpdateExpression (SET, REMOVE, ADD, DELETE)
- Global Secondary Indexes (GSI)
- DynamoDB Streams (NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES, KEYS_ONLY)
- TTL with background sweep
- ReturnConsumedCapacity (TOTAL and INDEXES)
- ReturnValuesOnConditionCheckFailure
- ClientRequestToken idempotency for TransactWriteItems
- PartiQL SELECT, INSERT, UPDATE, DELETE with EXISTS/BEGINS_WITH functions
- Pagination with LastEvaluatedKey/ExclusiveStartKey (1MB page limit)
- Item size validation (400KB limit)
- Transaction size validation (4MB aggregate, 100 action limit)
- Batch size limits (16MB response, 100 keys for get, 25 items for write)
Dynoxide's DynamoDB API semantics and validation logic were informed by dynalite, the excellent DynamoDB emulator built on LevelDB by Michael Hart and now maintained by the Architect team.
Dynoxide is a clean-room Rust implementation. No code was ported directly, but dynalite's thorough approach to matching live DynamoDB behaviour, including edge cases and error messages, was an invaluable reference.
Dynoxide uses SQLite as its storage layer. (AWS's DynamoDB Local also uses SQLite internally.)
Dual-licensed under MIT and Apache 2.0. See LICENSE-MIT and LICENSE-APACHE.