#dynamoby karrer.solutions v0.2.0

DynamoDB for Elixir, the Ecto way.

Typed schemas that generate composite keys, a supervised Repo that owns credentials and retries, request builders you can unit-test, and one %Dynamo.Error{} for every failure.

{:dynamo, github: "bmalum/dynamo"}

lib/my_app/order.exDynamo.Schema
defmodule MyApp.Order do
  use Dynamo.Schema

  item do
    table "app_data"

    field :user_id,   :string,       partition_key: true
    field :placed_at, :utc_datetime, sort_key: true
    field :order_id,  :string,       sort_key: true
    field :total,     :decimal
    field :status,    :string, default: "pending"
  end
end

# %MyApp.Order{user_id: "u1", order_id: "o1",
#              placed_at: ~U[2024-01-15 10:00:00Z]}
Dynamo.Schema.primary_key/1
pk"order#u1"
entity_name # user_id
sk"2024-01-15T10:00:00Z#o1"
placed_at # order_id. A nil partition key or a value containing # is an error, never a sentinel.

One item, start to finish

Every snippet below runs against the library's test suite. The same code appears in the README and guides.

Define the repo once

The Repo is a supervised process tree. It resolves credentials (config, environment, ECS, IMDSv2) and refreshes them before they expire, owns region and endpoint, retries throttling and 5xx with jittered backoff, and emits telemetry for every request.

Point it at DynamoDB Local in dev, at a Req.Test plug in tests, at AWS in prod. Nothing else changes.

defmodule MyApp.Dynamo do
  use Dynamo.Repo, otp_app: :my_app
end

# config/runtime.exs
config :my_app, MyApp.Dynamo,
  region: System.get_env("AWS_REGION", "eu-central-1"),
  endpoint: System.get_env("DYNAMO_ENDPOINT")

# application.ex
children = [MyApp.Dynamo]

Write it

Fields are dumped with their declared type. Nil fields are omitted, so an item never carries NULL noise. The condition is a keyword list; Dynamo generates the expression, the placeholders and the typed values.

Embedded schemas and {:list, type} keep their types on the way back: dates stay dates, decimals stay decimals.

order = %MyApp.Order{
  user_id: "u1",
  placed_at: DateTime.utc_now(),
  order_id: "o1",
  total: Decimal.new("9.99")
}

{:ok, order} = MyApp.Dynamo.put(order, condition: [pk: :not_exists])

# {:error, %Dynamo.Error{type: :conditional_check_failed}} if it existed

Read it back

Populated sort-key fields on the struct become a begins_with on the partial key, so "all of January" is one line. Pages carry last_evaluated_key; stream/2 paginates lazily and segments: turns a scan into a genuinely parallel one with backpressure.

{:ok, %Dynamo.Page{items: orders, last_evaluated_key: key}} =
  MyApp.Dynamo.query(%MyApp.Order{user_id: "u1"},
    sort_key: {:between, "2024-01-01", "2024-01-31~"},
    filter: [total: {:gt, Decimal.new("100")}],
    descending: true,
    limit: 50
  )

# January, via the struct: sk begins_with "2024-01"
MyApp.Dynamo.query(%MyApp.Order{user_id: "u1",
  placed_at: ~U[2024-01-01 00:00:00Z]})

# index keys keep the field's type
MyApp.Dynamo.query(%MyApp.Order{status: "shipped"},
  index: "StatusIndex", sort_key: {:gt, ~U[2024-01-01 00:00:00Z]})

MyApp.Dynamo.stream(MyApp.Order, segments: 8) |> Enum.count()

Change it

SET, REMOVE, ADD and DELETE are built from a keyword list. Key fields cannot be updated; that is a validation error before any request is sent, not a corrupted item afterwards.

The client retries transport timeouts, so guard counters with a version condition or use a transaction with an idempotency token.

{:ok, nil} =
  MyApp.Dynamo.update(order,
    [status: "shipped", version: {:increment, 1}],
    condition: [version: order.version]
  )

# UpdateExpression:    SET #a0 = :v0, #a1 = #a1 + :v1
# ConditionExpression: #c0 = :c1

MyApp.Dynamo.update(order,
  tags: {:add, MapSet.new(["gift"])},
  legacy: :remove
)

Do several things atomically

Transactions take the same structs and the same option names. On cancellation the error carries one reason per operation, in order, so you know which check failed.

MyApp.Dynamo.transaction(
  [
    {:update, %Account{id: "src"}, [balance: {:decrement, 100}],
      condition: [balance: {:gte, 100}]},
    {:update, %Account{id: "dst"}, [balance: {:increment, 100}]},
    {:put, %Transfer{id: "t1", amount: 100}, condition: [id: :not_exists]}
  ],
  client_request_token: "transfer-t1"
)

# on failure:
# {:error, %Dynamo.Error{type: :transaction_canceled,
#   cancellation_reasons: [%{code: "ConditionalCheckFailed"},
#                          %{code: "None"}, %{code: "None"}]}}

What is actually stored

No hidden magic in the table. The key attributes are plain strings you can read in the console, and any other client can query them. raw_keys: true maps tables you did not design around this layout.

DynamoDB item
{
  "pk":        {"S": "order#u1"},
  "sk":        {"S": "2024-01-15T10:00:00Z#o1"},
  "user_id":   {"S": "u1"},
  "placed_at": {"S": "2024-01-15T10:00:00Z"},
  "order_id":  {"S": "o1"},
  "total":     {"N": "9.99"},
  "status":    {"S": "pending"},
  "version":   {"N": "0"}
}
What get/2 returns
%MyApp.Order{
  user_id: "u1",
  placed_at: ~U[2024-01-15 10:00:00Z],
  order_id: "o1",
  total: Decimal.new("9.99"),
  status: "pending",
  version: 0
}

Vector search, in the same table

DynamoDB has native vector indexes since August 2026. Dynamo maps them onto a typed field and a schema declaration, and routes SearchVectors to the separate AWS endpoint it lives on.

Declare {:vector, n} with your embedding model's dimensions and a vector_index with a distance function. A partition key on the index scopes every search to one tenant and raises its throughput quota; inline filters narrow further.

Results come back as {struct, score} pairs, most similar first. Only projected attributes are returned, and key fields are recovered from the item key, so you get real structs without a second lookup.

mix dynamo.create_table --schema creates the index. Tables with vector indexes must be on-demand; the index is eventually consistent.

defmodule Kb.Chunk do
  use Dynamo.Schema

  item do
    table "kb"
    field :doc_id, :string, partition_key: true
    field :chunk_id, :string, sort_key: true
    field :tenant, :string
    field :lang, :string
    field :text, :string
    field :embedding, {:vector, 1024}

    vector_index "Semantic",
      field: :embedding,
      distance: :cosine,
      projection: :include, projected_attributes: [:text, :lang],
      partition_key: :tenant,
      filters: [:lang]
  end
end

embedding = Bedrock.embed("how do I rotate credentials?")

{:ok, %{results: hits}} =
  Kb.Repo.search_vectors(Kb.Chunk, "Semantic", embedding,
    top_k: 8,
    filter: [tenant: "acme", lang: "en"]
  )

for {%Kb.Chunk{text: text}, score} <- hits, do: IO.puts("#{score}  #{text}")

When it fails, you get one shape

AWS error bodies are normalised at the client boundary. Nothing above it sees a raw __type. Match on the atom; retryable errors are already retried for you.

typemeaning
:validation_errorlocal (nil key, unknown option, wrong type) or AWS
:conditional_check_failedyour condition: was false
:resource_not_foundtable or index does not exist
:throttledretried with backoff; retryable?: true
:transaction_canceledwith cancellation_reasons per operation
:transport_errortimeout or connection failure, retried
case MyApp.Dynamo.put(user, condition: [pk: :not_exists]) do
  {:ok, user} ->
    {:ok, user}

  {:error, %Dynamo.Error{type: :conditional_check_failed}} ->
    {:error, :already_exists}

  {:error, %Dynamo.Error{retryable?: true} = e} ->
    schedule_retry(e)

  {:error, %Dynamo.Error{} = e} ->
    Logger.error(Exception.message(e))
end

:telemetry.attach("dynamo", [:dynamo, :request, :stop],
  fn _, %{duration: d}, meta, _ ->
    ms = System.convert_time_unit(d, :native, :millisecond)
    Logger.debug("#{meta.action} #{meta.table} #{ms}ms #{meta.result}")
  end, nil)

Four layers, one owner per decision

Each layer can be tested without the one below it. Request building is pure; the test suite checks it against the exact JSON DynamoDB expects, and a live suite runs the same scenarios against AWS.

Dynamo.Schema
Typed fields, composite keys, embedded schemas, custom Dynamo.Types. One primary_key/1 used by every operation. Compile-time errors for a missing table, missing key, unknown option or type.
Dynamo.Request
Struct plus options in, {action, payload} out. No I/O. Dynamo.Update and Dynamo.Condition build the expressions.
Dynamo.Client
SigV4, retries with jittered backoff, telemetry, and the only place that reads an AWS error body.
Dynamo.Repo
The supervised owner of client and credentials, and the module you call. Batches are chunked and their unprocessed items retried; streams run under its Task.Supervisor.

Made for coding agents too

Kiro, Claude Code, Cursor and friends get a skill that encodes the workflow and the rules, not just the API. The same text is what the library's own maintainers work from.

The dynamo-elixir skill walks an agent through confirming the library version, modelling access patterns before writing a schema, using the Repo, matching on error types and testing without AWS. It links a one-page cheat sheet and the 0.1 to 0.2 migration table.

For retrieval, llms.txt indexes everything and llms-full.txt holds the complete documentation in one file.

# Kiro
mkdir -p .kiro/skills/dynamo-elixir/references
curl -sSo .kiro/skills/dynamo-elixir/SKILL.md \
  https://elixir-dynamodb.dev/skills/dynamo-elixir/SKILL.md
curl -sSo .kiro/skills/dynamo-elixir/references/cheatsheet.md \
  https://elixir-dynamodb.dev/skills/dynamo-elixir/references/cheatsheet.md

# Claude Code: same files under .claude/skills/dynamo-elixir/

Questions people ask

Is Dynamo an ORM for DynamoDB?

No. It is a Repo and schema layer in the spirit of Ecto: typed structs, generated composite keys and pure request builders, with DynamoDB's own model (partition and sort keys, GSIs, expressions) kept fully visible. There is no query planner and no lazy loading; every call maps to exactly one DynamoDB API action.

How does Dynamo compare to ExAws.Dynamo?

ExAws.Dynamo is a low-level API wrapper: you build request maps and decode responses yourself. Dynamo adds typed schemas that dump and load items, composite key generation, a supervised Repo with credential resolution and retries, a condition DSL, pagination and streams, and a single normalised error type. It talks to DynamoDB directly over Req with its own SigV4 signing and does not depend on ExAws.

Does it support single-table design?

Yes. Several schemas share one table; entity_name prefixes the partition key so items of different types never collide, and entity_name ParentModule puts child items into the parent's partition. Sort keys can carry a type prefix through a field default, and mix dynamo.create_table accepts several --schema flags to create one table with all indexes.

How are DynamoDB errors reported?

Every failure is {:error, %Dynamo.Error{}} with a categorised type such as :conditional_check_failed, :resource_not_found, :throttled or :transaction_canceled, plus aws_type, HTTP status, retryable? and, for transactions, one cancellation reason per operation. Retryable errors are retried by the client with jittered exponential backoff before you see them.

How do I test code that uses Dynamo without AWS?

Request building is pure, so you can assert on Dynamo.Request output directly. For integration tests point the Repo at a Req.Test plug (req_options: [plug: {Req.Test, MyApp.Dynamo}]) or at DynamoDB Local via endpoint: "http://localhost:8000". The library ships an in-memory fake DynamoDB you can copy.

Which Elixir and OTP versions are supported?

Elixir 1.16 and newer on OTP 26 and newer. Dependencies are Req, Jason and Telemetry; Decimal is optional and enables the :decimal type.

Does Dynamo support DynamoDB vector search?

Yes. Declare a {:vector, n} field and a vector_index (cosine, dot product or euclidean, optional partition key and inline filters) in the schema; mix dynamo.create_table creates the index and Repo.search_vectors/4 returns the top-k items as {struct, score} pairs. The client routes SearchVectors to AWS's separate search endpoint automatically. Vector indexes require on-demand billing and are eventually consistent.

Can AI coding agents use Dynamo?

Yes. The site publishes an agent skill (SKILL.md with a cheat sheet and a 0.1 migration guide) at /skills/dynamo-elixir/, an llms.txt index and the full documentation as one text file at /llms-full.txt. Copy the skill folder into your agent's skills directory.

Get started

Elixir 1.16 or newer. decimal is optional and enables the :decimal type.

  1. Add the dependency and configure a repo.

    # mix.exs
    {:dynamo, github: "bmalum/dynamo"},
    {:decimal, "~> 2.0"}
    
    # lib/my_app/dynamo.ex
    defmodule MyApp.Dynamo do
      use Dynamo.Repo, otp_app: :my_app
    end
    
    # config/dev.exs
    # DynamoDB Local: docker run -p 8000:8000 amazon/dynamodb-local
    config :my_app, MyApp.Dynamo,
      region: "eu-central-1",
      endpoint: "http://localhost:8000",
      access_key_id: "local",
      secret_access_key: "local"
  2. Define a schema and create its table (indexes included) from it.

    mix dynamo.create_table --schema MyApp.Order --repo MyApp.Dynamo
  3. Add MyApp.Dynamo to your supervision tree and call it. Continue with the getting-started guide, data modelling (single-table design, GSIs, embedded schemas) and testing.