Embedded SDK
Dynoxide can be used as a Rust library, calling the database directly without any HTTP overhead. Operations run in-process using typed requests, without the HTTP round trip.
It also runs on platforms where DynamoDB Local can't, including iOS.
Building the crate needs Rust 1.88 or later.
Add the dependency
[dependencies]
dynoxide-rs = { version = "3.1", default-features = false, features = ["native-sqlite"] }
For encrypted storage:
[dependencies]
dynoxide-rs = { version = "3.1", default-features = false, features = ["encryption"] }
Basic usage
use dynoxide::Database;
// In-memory database (great for tests)
let db = Database::memory().unwrap();
// Persistent, file-backed
let db = Database::new("data.db").unwrap();
// Encrypted (requires the encryption feature)
let db = Database::new_encrypted("data.db", "your-hex-key").unwrap();
The API uses typed DynamoDB-compatible request and response structs from dynoxide::actions. You can construct them directly or deserialise DynamoDB JSON into them with serde_json::from_value. You call the same operations you'd call over HTTP - CreateTable, PutItem, Query, and so on - but without the network round trip.
Serving HTTP from your own program
With the http-server feature (on by default), server::serve_with_shutdown serves the DynamoDB API on a listener you bind until a future you supply resolves. It then stops accepting, closes idle keep-alive connections, lets running requests finish and returns, so the database is released when it does. It arrived in crate 3.1.0.
use dynoxide::Database;
#[tokio::main]
async fn main() -> Result<(), String> {
let db = Database::new("data.db").map_err(|e| e.to_string())?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000")
.await
.map_err(|e| e.to_string())?;
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
// Hand `stop` to whatever decides when to shut down.
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let _ = stop.send(());
});
dynoxide::server::serve_with_shutdown(listener, db, async {
let _ = stopped.await;
})
.await
}
Before it existed, stopping an embedded server meant aborting its task. That left the connection tasks holding the database, so a file-backed database's files stayed open, and Windows wouldn't let you delete them.
It installs no signal handler; server::start is the one that stops on Ctrl-C or SIGTERM, and now runs on serve_with_shutdown itself. If you delete a file-backed database straight after it returns, drop your own handle first and retry the delete briefly: the last connection task can let go of its clone a moment after the return. A request that never finishes holds the shutdown for as long as it runs. The rustdoc on serve_with_shutdown has the detail.
Upgrading to crate 3.1.0
Product 1.4.0 ships with crate 3.1.0. It adds server::serve_with_shutdown and changes no public types, but a few requests built in Rust are now checked the way the same request sent over HTTP is checked:
Database::scanandDatabase::queryrefuse aLimitof 0.Database::scanalso refuses aSelectorReturnConsumedCapacityoutside its enum and aSegmentabove 999999. Scan's member errors now come fromDatabase::scanrather than from decoding the request, so aScanRequestwith aSelectofBADdecodes.- An empty
UpdateExpressioncomes back asDynoxideError::EnvelopedValidationrather thanValidationException. The message is unchanged. Database::search_vectorsrefuses anIndexNameshorter than three characters, an emptySearchVectoror aTopKbelow 1 with the same message as over HTTP, where it used to let them through to fail later.
The database schema stays at version 11, so a file 1.3.0 opened needs no further upgrade. Coming from crate 2.x, read the 3.0.0 notes below as well.
Upgrading to crate 3.0.0
Product 1.3.0 ships with crate 3.0.0. The two version streams are separate: use the crate number for Cargo, and the product number for binaries, npm and containers.
The Rust API changes are:
expressionsandpartiqlare private. Send expressions and PartiQL statements throughDatabaseand the public request types inactions. Code importing the parsers needs to move to those entry points.actions::execute_transaction::ParameterizedStatementandactions::execute_statement::ExecuteStatementRequestgainreturn_values_on_condition_check_failure.actions::batch_execute_statement::BatchStatementErrorgainsitem. Add the fields to struct literals, or use..Default::default()where appropriate.actions::index_capacity::WriteCapacity::condition_checktakes the table's vector indexes so it can charge a vector at its billed size.
The minimum supported Rust version remains 1.88 for the crate and every build of it. Running the test suite needs Rust 1.94.1 because of its AWS SDK development dependencies; that requirement does not apply to installing or embedding Dynoxide.
AttributeValue serialisation also sorts set members into DynamoDB's order, and write normalisation sorts sets in place, so don't rely on their construction order surviving serde.
Product 1.3.0 brings persistent databases to schema version 11. Keep a copy before upgrading if the file has a multi-attribute GSI or a vector index and you may return to 1.2.x. Files without either still read correctly in 1.2.x. The storage guide explains the affected key shapes, encrypted and browser files, newer-schema refusals and restoring older snapshots.
Embedded server output
ServerOptions::quiet() suppresses informational startup and shutdown messages. Pass it to server::start_with_options or mcp::serve_http_with_options; ServerOptions is available at the crate root and as server::ServerOptions. Warnings and errors still print. This is the embedded equivalent of --log quiet.
Table and index statistics
Storage::item_count_and_size, gsi_item_count_and_size, lsi_item_count_and_size and vector_item_count_and_size return the live counts and sizes used by DescribeTable. See capacity and statistics for how vectors are sized.
Upgrading to crate 2.0.0
The crate moved to 2.0.0 while the product stayed at 1.2.0. They are separate version streams, so cargo install dynoxide-rs --version 1.2.0 finds nothing - 1.2.0 is the product number.
One source-breaking change, and only for library consumers:
ImportCommandgained adata_modelfield, carrying the OneTable schema that rebuilds keys after anonymisation. Struct literals constructing one need the field adding.
The DynamoDB wire API and the CLI, server and MCP surfaces are unchanged.
Upgrading to 0.12
0.12.0 is source-breaking for library consumers, and only for them. The DynamoDB wire API and the CLI, server and MCP surfaces are unchanged, so if you run the binary rather than depend on the crate, nothing here affects you.
Two public types gained a field and became #[non_exhaustive]:
partiql::parser::Statement- theUpdateandDeletevariants carry areturningfield, which is what makes the new PartiQLRETURNINGsupport work.actions::batch_execute_statement::BatchStatementResponse- gained atable_namefield.
This is historical guidance for 0.12. The partiql parser module is private from crate 3.0.0; use the public action request types instead.
Feature flags
| Flag | Default | Description |
|---|---|---|
native-sqlite |
Yes | Bundles plain SQLite. No OpenSSL. |
http-server |
Yes | The axum HTTP server exposing the DynamoDB JSON API. |
mcp-server |
Yes | The MCP server, over stdio and Streamable HTTP. |
import |
Yes | The dynoxide import CLI, with anonymisation. |
encryption |
No | SQLCipher with vendored OpenSSL. Adds Database::new_encrypted(). |
encryption-cc |
No | As encryption, but Apple CommonCrypto instead of OpenSSL. For macOS and iOS. |
encrypted-full |
No | encryption plus the server, MCP and import features. |
wasm-sqlite |
No | The wasm32 browser backend. Pulls in neither native SQLite nor the CLI. |
native-sqlite, encryption and wasm-sqlite are mutually exclusive - they select different SQLite backends.
This one bites in workspaces. Cargo unifies features across a workspace, so if one crate depends on dynoxide-rs with default features (picking up native-sqlite) and another asks for encryption, both activate and the build fails with a backend conflict. Set default-features = false on every dynoxide-rs dependency in the workspace.
Testing
The embedded mode is particularly useful for integration tests. Each test can spin up its own Database::memory() instance - there's no shared state, no port conflicts, and startup is roughly 0.2ms.
#[test]
fn test_user_creation() {
let db = Database::memory().unwrap();
// create table, put item, assert...
}
No test fixtures, no cleanup, no waiting for a server to start. The database exists for the lifetime of the Database value and is dropped automatically.
For the full API surface and feature flags, see the crate documentation on crates.io.