Writing Data¶
All writes go through session entry points that return a
WriteSegmentBuilder. Assign bin values via dict
(or chained bin operations), then call .execute().
Write Verbs¶
Method |
Behavior |
Record must exist? |
|---|---|---|
|
Create or update |
No |
|
Create only |
No (fails if exists) |
|
Update only |
Yes (fails if missing) |
|
Replace all bins |
Yes (fails if missing) |
|
Remove record |
No |
|
Reset TTL |
Yes |
|
Check existence |
No |
Dict Pattern¶
Set bins by passing a dict to .put() — the common case:
users = DataSet.of("test", "users")
await (
session.upsert(users.id(1))
.put({"name": "Alice", "age": 30, "active": True})
.execute()
)
Bin Chaining¶
Set bins individually with the chainable .bin().set_to() pattern — useful when
building bins conditionally or mixing in per-bin operations:
await (
session.upsert(users.id(1))
.bin("name").set_to("Alice")
.bin("age").set_to(30)
.bin("active").set_to(True)
.execute()
)
Increment¶
await (
session.update(users.id(1))
.bin("login_count").add(1)
.execute()
)
GeoJSON Bins¶
Use set_to_geo_json(...) to write a bin as a GeoJSON value. The bin’s
server-side particle type is GEOJSON, not STRING, which makes it eligible for
GEO2DSPHERE indexing and geoCompare(...) queries.
places = DataSet.of("test", "places")
await (
session.upsert(places.id("space_needle"))
.bin("loc").set_to_geo_json('{"type":"Point","coordinates":[-122.349,47.620]}')
.execute()
)
AeroCircle and Polygon values use the same method — only the GeoJSON string differs.
HyperLogLog Bins¶
HllConfig describes a sketch’s precision
(index_bit_count + optional min_hash_bit_count). Initialize a new sketch
with hll_init(...), then accumulate elements with hll_add(...):
from aerospike_sdk import HllConfig
visitors = DataSet.of("test", "visitors")
await (
session.upsert(visitors.id("day_1"))
.bin("h").hll_init(HllConfig.of(14))
.bin("h").hll_add(["user-1", "user-2", "user-3"])
.execute()
)
Each write method (hll_init, hll_add, hll_set_union) accepts four
keyword-only flags:
Flag |
Effect |
|---|---|
|
Fail with |
|
Fail with |
|
Suppress the mode-constraint error from |
|
( |
Passing both create_only=True and update_only=True raises ValueError
immediately at the call site (before the wire request).
# Create the sketch only if it doesn't exist; don't error if it does.
await (
session.upsert(visitors.id("day_2"))
.bin("h").hll_init(HllConfig.of(14), create_only=True, no_fail=True)
.execute()
)
To inspect a sketch’s bit widths, call hll_describe() and decode the
two-element list result via RecordResult.get_hll_config(bin_name):
rs = await session.query(visitors.id("day_1")).bin("h").hll_describe().execute()
result = await rs.first_or_raise()
config = result.get_hll_config("h")
# config == HllConfig.of(14)
Insert (Fail if Exists)¶
stream = await (
session.insert(users.id(99))
.put({"name": "New User"})
.execute()
)
result = await stream.first_or_raise()
Replace (Overwrite All Bins)¶
await (
session.replace(users.id(1))
.put({"name": "Alice Updated"})
.execute()
)
# Only "name" bin remains; "age" and "active" are removed
Delete¶
# Single key
await session.delete(users.id(1)).execute()
# Multiple keys
await session.delete(*users.ids(1, 2, 3)).execute()
Durable delete¶
Aerospike supports two delete modes:
a normal delete that removes the record (and its lineage) outright, and
a durable delete that leaves a tombstone so a strongly-consistent (SC) cluster can resolve the deletion across partitions.
The SDK exposes both per-operation overrides and builder defaults:
Method |
Scope |
Effect |
|---|---|---|
|
one operation |
Force durable delete for this delete only |
|
one operation |
Force a non-durable delete for this delete only |
|
builder |
Prefer durable when resolving Behavior defaults — typical for SC namespaces |
|
builder |
Prefer non-durable when resolving Behavior defaults |
The override (with_* / without_*) wins over the default; the default
folds into Behavior settings resolution.
# Force durable on this single delete (per-op override)
await session.delete(users.id(5)).with_durable_delete().execute()
# Use durable as the default for every delete in this segment (SC-friendly)
await (
session.delete(*users.ids(1, 2, 3))
.default_with_durable_delete()
.execute()
)
Conditional Writes¶
Filter writes server-side with .where():
await (
session.update(users.id(1))
.where("$.age >= 18")
.bin("verified").set_to(True)
.execute()
)
Records that don’t match the filter are skipped. Use .fail_on_filtered_out()
to raise an error instead:
await (
session.update(users.id(1))
.where("$.age >= 18")
.fail_on_filtered_out()
.bin("verified").set_to(True)
.execute()
)
Generation Check (Optimistic Locking)¶
await (
session.update(users.id(1))
.ensure_generation_is(5)
.bin("balance").set_to(100)
.execute()
)
TTL / Expiration¶
Three equivalent shapes for setting a record’s TTL — pick whichever your call site already has handy:
from datetime import datetime, timedelta, timezone
# Raw seconds:
await session.upsert(users.id(1)).expire_record_after_seconds(3600).put({"session_token": "abc123"}).execute()
# A timedelta:
await session.upsert(users.id(1)).expire_record_after(timedelta(hours=1)).put({"session_token": "abc123"}).execute()
# An absolute point in time:
await session.upsert(users.id(1)).expire_record_at(datetime.now(timezone.utc) + timedelta(hours=1)).put({"session_token": "abc123"}).execute()
expire_record_at accepts either a timezone-aware or naive datetime (a naive
value is interpreted in local time) and raises ValueError if the datetime
is in the past.
The expire_record_after_seconds / expire_record_after forms are
pass-through — they do not range-check the value. Non-positive values select
the TTL sentinels:
Value |
Meaning |
Named equivalent |
|---|---|---|
|
never expire |
|
|
keep the existing TTL |
|
|
use the namespace default |
|
So expire_record_after_seconds(-1) is equivalent to never_expire(). Only a
value the client cannot represent — a negative other than the sentinels, or one
outside the 32-bit range — is rejected, and only when the write is built.
Chain-wide defaults¶
Each verb above has a default_ counterpart that applies to every operation in
the chain that does not set its own TTL — default_expire_record_after,
default_expire_record_after_seconds, default_expire_record_at,
default_never_expire, default_with_no_change_in_expiration, and
default_expiry_from_server_default. default_where does the same for filters.
They are available from any segment, so a chain opened with a write verb can set them directly:
await (
session.upsert(users.id(1))
.default_expire_record_after(timedelta(minutes=10))
.bin("session_token").set_to("abc123")
.execute()
)
A default is only a default: an operation that sets its own TTL keeps it.
await (
session.upsert(users.id(1))
.default_expire_record_after(timedelta(minutes=10))
.expire_record_after_seconds(120) # this operation expires in 2 minutes
.bin("session_token").set_to("abc123")
.execute()
)
The scope is the chain, and it ends at execute() — a default never carries over
to the next chain, even from the same session. Within the chain it applies to
every operation regardless of where you set it (before or after the operations
it covers), and to every key of a multi-key segment:
await (
session.upsert(*users.ids(1, 2, 3))
.default_expire_record_after(timedelta(minutes=10))
.bin("status").set_to("migrated")
.execute()
) # all three records expire in 10 minutes
await session.upsert(users.id(4)).bin("status").set_to("migrated").execute()
# new chain: no TTL
For a TTL on every write an application makes, set it on the
Behavior the session carries instead — that is session-scoped.
A chain default is for one unit of work.
Batch Writes¶
Multiple keys with the same operation:
await (
session.upsert(*users.ids(1, 2, 3))
.bin("status").set_to("migrated")
.execute()
)
Mixed operations across different keys are handled automatically when you chain multiple write segments.
As with batch reads, any node whose sub-batch holds a single key is sent a regular single-record command instead of a batch request, automatically and per-node — there is nothing to gain from splitting out size-1 groups in application code.
A UDF apply can be part of that mix. Chain execute_udf(*keys).function(...)
into a batch alongside reads, writes, and deletes; the per-key UDF applies
travel in the same single round-trip rather than a separate call:
await (
session.query(users.id(1)) # read
.execute_udf(users.id(2)) # per-key UDF apply
.function("stats", "increment").passing("visits", 1)
.delete(users.id(3)) # delete
.execute()
)
A batch that is only UDF applies (no other op types) uses the dedicated apply-many path instead — both produce one round-trip.
As with batch reads, a batch write is a single server request. To
write one payload to a window of keys as independent commands submitted
together, session.put_many(keys, bins) fuses them into one client-side
submission and reports an outcome per key, positionally. It is async-only — see
Async window API.
Tabular Writes¶
When many records share the same bins, declare the bins once and supply one row per record. The verb takes the dataset rather than keys; each row starts with the record’s id and continues with one value per bin, in order:
users = DataSet.of("test", "users")
await (
session.upsert(users).bins("name", "age")
.row(1, "Tim", 312)
.row(2, "Bob", 25)
.row(3, "Jane", 46)
.execute()
)
rows(...) takes any iterable of (id, *values) sequences, so data you
already hold as tuples, or a generator over a file, goes straight in:
people = [(10, "Tim", 312), (11, "Bob", 25), (12, "Jane", 46)]
await session.upsert(users).bins("name", "age").rows(people).execute()
The rows leave as one batch and come back as one RecordStream with a result
per row, exactly as a hand-chained upsert(key).put(...) sequence would.
insert, update and replace take the same shape and report their usual
per-record outcomes, so an insert row whose record already exists shows up
as KEY_EXISTS_ERROR on that row alone.
The TTL and generation verbs apply to the most recent row; the default_
verbs cover every row that sets none of its own, and with_txn runs the whole
batch in a transaction:
await (
session.insert(users).bins("name", "age")
.row(1, "Tim", 312)
.row(2, "Bob", 25).expire_record_after(timedelta(hours=1))
.row(3, "Jane", 46).ensure_generation_is(4)
.default_expire_record_after(timedelta(days=30))
.execute()
)
A row with the wrong number of values is rejected when it is added, not on the server.
execute() vs stream()¶
Every query-path builder — the session.query() chain and the chained write
segments — exposes two terminal methods with different
result-delivery semantics. execute() is the default; reach for
stream() only when buffering a large result set is expensive.
execute()— buffered. Awaits every per-key result, then returns aRecordStreambacked by a fully-materialized list. Writes are guaranteed to have completed server-side by the time this method returns; subsequent reads observe the new state without races. Safe for “fire-and-forget” use (await the call and discard the returned stream). Use this for most workloads.stream()— lazy. Returns aRecordStreamthat yields oneRecordResultper__anext__(__next__on sync) as each node responds. The first results are available as soon as the first node responds, without waiting for the rest. Peak memory stays bounded to the in-flight node responses — useful for large batches where buffering the full result list would be expensive.Caveats for
stream():Yields completion order, not input order. Each
RecordResultcarries its originating op’s input position in.index. Sort after collecting if you need positional results.No writes-complete-on-return guarantee. If you discard the returned stream without iterating, per-node tasks may still be in-flight when subsequent code runs — subsequent reads can race against pending writes.
Per-key errors land inline on
RecordResult(.is_ok=False,.exception); cluster-level errors raise mid-iteration.Close it if you abandon it early — drain fully, or use the context manager, so the producer is released promptly. See Closing a Stream.
# Lazy streaming — process records as they arrive.
# `async with` releases the stream on every exit path.
async with (
session.upsert(key1).bin("v").set_to(1)
.upsert(key2).bin("v").set_to(2)
.stream()
) as stream:
async for result in stream:
print(result.index, result.is_ok, result.key.value)
# When positional ordering is needed, collect + sort
async with (...).stream() as stream:
results = await stream.collect()
results.sort(key=lambda r: r.index)
Sync siblings: the sync chained builders’ execute() and .stream()
have the same contract; iterate the latter with for result in stream (rather
than async for) and use a plain with block to auto-close.