View Source Data modelling with Dynamo
Composite keys
Key fields are joined in declaration order with key_separator (default #).
The partition key is prefixed with the entity name so several entity types can
share a table.
defmodule Shop.Order do
use Dynamo.Schema
item do
table "shop"
entity_name "order"
field :tenant, :string
field :customer_id, :string
field :placed_at, :utc_datetime
field :order_id, :string
field :total, :decimal
partition_key [:tenant, :customer_id]
sort_key [:placed_at, :order_id]
end
end
{:ok, key} = Dynamo.Schema.primary_key(%Shop.Order{tenant: "acme", customer_id: "c1", placed_at: ~U[2024-01-15 10:00:00Z], order_id: "o1"})
key.pk #=> %{"S" => "order#acme#c1"}
key.sk #=> %{"S" => "2024-01-15T10:00:00Z#o1"}Rules:
- All partition key fields must be non-nil.
{:error, %Dynamo.Error{type: :validation_error}}otherwise. - Sort key fields may be partially populated (prefix).
%Shop.Order{tenant: "acme", customer_id: "c1", placed_at: dt}givessk = "2024-01-15T10:00:00Z", whichquery/2turns intobegins_with. - A value containing the separator is rejected. Keep
#unless your data contains it. prefix_sort_key: truerenders"placed_at#2024-…#order_id#o1"if you want self-describing keys.
Sort key strategies
Time ordering – put an ISO 8601 datetime first; descending: true gives newest first.
Repo.query(%Shop.Order{tenant: "acme", customer_id: "c1"}, sort_key: {:gte, "2024-01-01"}, descending: true)Type prefix in a single partition – sort_key [:kind, :id] with kind as "PROFILE", "ORDER", "ADDRESS":
Repo.query(%Account.Item{account_id: "a1", kind: "ORDER"}) # begins_with "ORDER"
Repo.query(%Account.Item{account_id: "a1"}, sort_key: {:eq, "PROFILE"})Range with BETWEEN – composite string keys compare lexicographically, so an
upper bound like "2024-01-31" excludes "2024-01-31T10:00:00Z#o1". Append ~
(0x7E, above every character that can follow) to make the bound inclusive:
Repo.query(%Shop.Order{...}, sort_key: {:between, "2024-01-01", "2024-01-31~"})Global secondary indexes
item do
table "shop"
field :order_id, :string, partition_key: true
field :status, :string
field :placed_at, :utc_datetime
field :total, :decimal
global_secondary_index "StatusIndex", partition_key: :status, sort_key: :placed_at
global_secondary_index "TotalIndex", partition_key: :status, sort_key: :total, projection: :keys_only
endGSI key attributes are the raw field values with their own types – no entity
prefix, no separator. Numbers stay N, so range conditions work numerically:
Repo.query(%Shop.Order{status: "paid"}, index: "TotalIndex", sort_key: {:gt, Decimal.new("100")})mix dynamo.create_table --schema Shop.Order creates the indexes with the right
attribute types. Consistent reads are rejected on GSIs (DynamoDB does not support them).
Single-table design
Several schemas can point at the same table; the entity prefix keeps them apart:
defmodule App.User do
use Dynamo.Schema
item do
table "app"
field :id, :string, partition_key: true
field :email, :string
end
end
defmodule App.Session do
use Dynamo.Schema
item do
table "app"
field :user_id, :string, partition_key: true # pk = "session#<user_id>"
field :token, :string, sort_key: true
field :expires_at, :utc_datetime
end
endTo store child items under the parent's partition (adjacency list), point the
child's entity_name at the parent module and put the type in the sort key:
defmodule App.Order do
use Dynamo.Schema
item do
table "app"
entity_name App.User # pk = "user#<id>" – inherited from App.User
field :id, :string, partition_key: true # the parent's id
field :kind, :string, sort_key: true, default: "order"
field :order_id, :string, sort_key: true
field :total, :decimal
end
end
Repo.query(%App.Order{id: "u1"}) # all orders of u1: begins_with(sk, "order")
Repo.get(%App.User{id: "u1"}) # the parent, with its own sort keyentity_name App.User is resolved at compile time, so renaming the parent
moves both entities together and a typo is a compile error. The string form
(entity_name "user") does the same thing without the compile-time link.
Give the parent a fixed sort-key value too (e.g. field :kind, default: "profile")
so parent and children never collide in the sort-key space. If two kinds share
a prefix ("order", "order_note"), query with an explicit
sort_key: {:begins_with, "order#"}.
Existing tables
For a table you did not design around this layout:
defmodule Legacy.Customer do
use Dynamo.Schema, raw_keys: true
item do
table "Customers"
field :CustomerId, :string, partition_key: true # attribute names are used as-is
field :Version, :integer, sort_key: true
field :Name, :string
end
endmix dynamo.generate_schema Customers writes this for you after sampling the table.
Embedded schemas
Nested structures keep their types when you declare them as embedded schemas:
defmodule Shop.Address do
use Dynamo.Schema, embedded: true # no table, keys or indexes
item do
field :street, :string
field :city, :string
field :geo, {:list, :float}
end
end
defmodule Shop.Customer do
use Dynamo.Schema
item do
table "shop"
field :id, :string, partition_key: true
field :billing, Shop.Address
field :shipping, {:list, Shop.Address}, default: []
field :prices, {:list, :decimal}
end
endA :map field would hand you string-keyed maps with floats and strings back;
an embedded schema gives you %Shop.Address{} with Decimal/Date values
intact. Any table schema can be embedded as well – only its declared fields
are stored, never the generated pk/sk.
Conditions without placeholders
Every condition:/filter: accepts a small DSL instead of a raw expression:
Repo.put(order, condition: [pk: :not_exists])
Repo.update(acct, [balance: {:decrement, 50}], condition: [balance: {:gte, 50}])
Repo.query(%Order{user_id: "u"}, filter: {:and, [status: {:in, ["open", "paid"]}], {:not, [flagged: true]}})
Repo.scan(Customer, filter: [{[:billing, :city], "Berlin"}, tags: {:contains, "vip"}])Values are dumped with the field's type; placeholders (#c0, :c0) are
generated so reserved words and odd attribute names are never a problem.
Nil, defaults and removal
Nil fields are omitted from written items. Reading an item that lacks an
attribute yields the struct default. To delete an attribute use
Repo.update(item, field: :remove); Repo.update(item, field: nil) stores an
explicit NULL, which loads back as nil too but is visible to
attribute_exists.
Vector search
Store embeddings next to the data they describe and search them without a second database:
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 = MyApp.Embeddings.embed("how do I rotate credentials?") # your model, e.g. Bedrock Titan
{:ok, %{results: hits}} =
Repo.search_vectors(Kb.Chunk, "Semantic", embedding, top_k: 8, filter: [tenant: "acme", lang: "en"])Decisions:
- Dimensions must equal your embedding model's output;
{:vector, n}checks it on write. Fewer dimensions cost less to store and search. - Distance:
:cosinefor raw embeddings,:dot_productif you normalise to unit vectors (same ranking, cheaper),:euclideanwhen magnitude matters. - Partition key on the index (
partition_key: :tenant) scopes each search to one value and raises throughput quotas per value. It becomes mandatory infilter:. Inlinefilters:narrow further with equality/range conditions. - Projection decides what a search returns and how much a write costs. The
vector itself is never returned unless you list it in
projection:. - Vector indexes require on-demand billing, are eventually consistent, and are immutable once created (add a new index instead).
- The item is a normal item:
get,query,updateall work on it. Updating the vector attribute re-indexes it.
Custom types
defmodule App.Type.Money do
use Dynamo.Type
def dump(%Money{amount: a, currency: c}), do: {:ok, %{"S" => "#{c}:#{a}"}}
def dump(_), do: :error
def load(%{"S" => s}) do
[c, a] = String.split(s, ":")
{:ok, %Money{amount: Decimal.new(a), currency: c}}
end
def load(_), do: :error
end
field :price, App.Type.MoneyFor values nested inside :map/:list/:any fields, implement Dynamo.Encodable
instead (one-way: they load back as plain maps).
Checklist
- [ ] List access patterns before choosing keys
- [ ] Partition key has high cardinality (no dates, no statuses)
- [ ] Sort key supports the range queries you listed
- [ ] GSIs for remaining patterns;
:keys_only/:includewhere possible - [ ] Items < 400 KB – reference S3 for blobs
- [ ] TTL attribute (
:integerepoch seconds) for ephemeral data - [ ]
entity_nameset explicitly (string) if the module might be renamed and data already exists