Install Python 3.11+ and uv. The CLI installs its matching native runtime through a platform wheel for Linux or macOS, on x64 or ARM64.
Create a new project in any directory:
uvx --from 'durable-actors[cli]' durable-actors init my-bank
cd my-bankThe generated project keeps the SDK in production dependencies and CLI tools in its development dependency group. uv run installs both; no repository checkout or Rust compiler is needed.
Each bank account is an actor. An actor has fields and methods. persisted() saves a field after each successful method call.
To track a bank balance, define src/actors.py:
from durable_actors import Actor, persisted
class BankAccount(Actor):
_balance: int = persisted(0)
def get_balance(self) -> int:
return self._balanceStart the local dev server from my-bank. The first run downloads the runtime:
uv run durable-actors devWait for Ready, then generate the client in a second terminal opened in the same my-bank directory:
uv run durable-actors generateThis creates a generated package with typed stubs for your actors. Each actor instance is addressed by an ID. Create a client.py that gets the demo account and calls a method:
from generated import actors
account = actors.BankAccount.get("demo")
print(account.get_balance())Run the client with Python:
uv run client.pyIt prints 0. The server and client both use http://127.0.0.1:7100 by default.
An actor starts when one of its methods is called, stays in memory while it is busy, and shuts down after sitting idle. Regenerate the client after changing an actor's methods.
Next, count the deposits made since the actor last woke up. ephemeral() keeps a field in memory only while the actor is awake. It resets when the actor shuts down.
from durable_actors import Actor, ephemeral, persisted
class BankAccount(Actor):
_balance: int = persisted(0)
_deposits_since_wake: int = ephemeral(0)
def get_balance(self) -> int:
return self._balance
def get_deposits_since_wake(self) -> int:
return self._deposits_since_wake
def deposit(self, amount: int) -> int:
self._balance += amount
self._deposits_since_wake += 1
return self._balanceSocket authorization is checked when connecting. The grant's connect_by_ms deadline limits when a connection can open; an accepted socket remains authorized until it closes, including while the actor sleeps. Reconnects require a valid grant. To revoke an existing connection, explicitly close it.
Actors handle WebSocket connections directly. The three type parameters of Actor describe each connection. Payloads are JSON.
- Metadata identifies the client. In this example, it is the
user_idpassed when the client connects. - Incoming messages are what a client can send. Here,
{"type": "ping"}, whichon_messageanswers with the balance. - Outgoing messages are what the actor sends, through either
socket.sendorbroadcast. Here,{"type": "balance", "balance": ...}.
The example also uses:
- Tags.
set_tagslabels one socket.on_connecttags each socket"customer". - Automatic responses. The gateway answers a raw text
pingwithpongwithout waking the actor.on_connectregisters the pair. A JSON{"type": "ping"}still runson_message. - Broadcasts.
broadcastsends to every connected socket.depositlimits it to sockets tagged"customer", andon_disconnectusesexcept_idsto skip the socket that just left. - Individual sends.
socket.sendsends to one socket.on_connectandon_messageuse it to send that client the current balance.
from typing import Literal
from durable_actors import Actor, ActorSocket, ephemeral, persisted
from pydantic import BaseModel
class Metadata(BaseModel):
user_id: str
class Incoming(BaseModel):
type: Literal["ping"]
class Outgoing(BaseModel):
type: Literal["balance"]
balance: int
Socket = ActorSocket[Metadata, Outgoing]
class BankAccount(Actor[Metadata, Incoming, Outgoing]):
_balance: int = persisted(0)
_deposits_since_wake: int = ephemeral(0)
def get_balance(self) -> int:
return self._balance
def get_deposits_since_wake(self) -> int:
return self._deposits_since_wake
def deposit(self, amount: int) -> int:
self._balance += amount
self._deposits_since_wake += 1
self.broadcast(Outgoing(type="balance", balance=self._balance), tags=("customer",))
return self._balance
def on_connect(self, socket: Socket) -> None:
socket.set_tags("customer")
self.set_websocket_auto_response("ping", "pong")
socket.send(Outgoing(type="balance", balance=self._balance))
def on_message(self, socket: Socket, message: Incoming) -> None:
socket.send(Outgoing(type="balance", balance=self._balance))
def on_disconnect(self, socket: Socket, code: int, reason: str, was_clean: bool) -> None:
self.broadcast(Outgoing(type="balance", balance=self._balance), except_ids=(socket.id,))emitted() publishes a public persisted() field to every connected client, so _balance becomes balance:
- When a client connects, it receives a
StateSnapshotwith the current emitted fields, heremessage.state.balance. - After a successful call changes the field, clients receive a
StateUpdatewith the changes.
from typing import Literal
from durable_actors import Actor, ActorSocket, emitted, ephemeral, persisted
from pydantic import BaseModel
class Metadata(BaseModel):
user_id: str
class Incoming(BaseModel):
type: Literal["ping"]
class Outgoing(BaseModel):
type: Literal["balance"]
balance: int
Socket = ActorSocket[Metadata, Outgoing]
class BankAccount(Actor[Metadata, Incoming, Outgoing]):
balance: int = emitted(persisted(0))
_deposits_since_wake: int = ephemeral(0)
def get_balance(self) -> int:
return self.balance
def get_deposits_since_wake(self) -> int:
return self._deposits_since_wake
def deposit(self, amount: int) -> int:
self.balance += amount
self._deposits_since_wake += 1
self.broadcast(Outgoing(type="balance", balance=self.balance), tags=("customer",))
return self.balance
def on_connect(self, socket: Socket) -> None:
socket.set_tags("customer")
self.set_websocket_auto_response("ping", "pong")
socket.send(Outgoing(type="balance", balance=self.balance))
def on_message(self, socket: Socket, message: Incoming) -> None:
socket.send(Outgoing(type="balance", balance=self.balance))
def on_disconnect(self, socket: Socket, code: int, reason: str, was_clean: bool) -> None:
self.broadcast(Outgoing(type="balance", balance=self.balance), except_ids=(socket.id,))A connecting client sees the snapshot, then updates as deposits land:
from durable_actors import StateSnapshot, StateUpdate
from generated import actors
account = actors.BankAccount.get("demo")
with account.connect(actors.BankAccount.Metadata(user_id="ada")) as socket:
for message in socket:
if isinstance(message, StateSnapshot):
print(message.state.balance)
elif isinstance(message, StateUpdate):
print(message.changes.balance)
else:
print(message)Each actor has one alarm. Setting it again replaces the previous timestamp. When the timestamp is due, the runtime wakes the actor and runs on_alarm.
import time
from durable_actors import Actor, persisted
class Reminder(Actor):
sent: bool = persisted(False)
def schedule(self, delay_ms: int) -> None:
self.alarm.set(time.time_ns() // 1_000_000 + delay_ms)
def on_alarm(self) -> None:
self.sent = TrueAn actor runs one call at a time by default. Python methods run in a worker thread, and an @interleave method does not hold the actor while it runs: other calls can start at any point during it, not only while it waits on I/O. Protect state that overlapping calls share, for example with a threading.Lock in an ephemeral() field.
Add a second actor for wire transfers to src/actors.py:
from durable_actors import Actor, emitted, interleave, persisted
class ClearingNetwork:
def submit(self, reference: str) -> str:
return f"receipt:{reference}"
clearing_network = ClearingNetwork()
class Wire(Actor):
status: str = emitted(persisted("requested"))
def get_status(self) -> str:
return self.status
@interleave
def submit(self, reference: str) -> str:
receipt = clearing_network.submit(reference)
self.status = "submitted"
return receiptget_status can run while submit is still in progress.
Interleaving changes failure handling for the whole class. A failed call normally rolls back its field and SQLite changes. Once any method in a class uses @interleave, no call in that class rolls back, because overlapping calls may already have used the changes.
Each actor has its own SQLite database at self.db. self.db.exec(sql, *bindings) runs one statement and returns its rows as dictionaries. Here, deposit records each deposit in a ledger table:
from durable_actors import Actor, emitted, persisted
class BankAccount(Actor):
balance: int = emitted(persisted(0))
def deposit(self, amount: int) -> int:
self.db.exec("CREATE TABLE IF NOT EXISTS ledger (amount INTEGER)")
self.balance += amount
self.db.exec("INSERT INTO ledger (amount) VALUES (?)", amount)
return self.balance- SQL writes and
persisted()fields commit together when the method or socket hook succeeds. - Bind values with
?placeholders. Values can bestr,int,float,bytes, orNone. self.dbis available only while a method or socket hook runs.- The runtime owns transactions and the database file, so
BEGIN,COMMIT,END,ROLLBACK,SAVEPOINT,RELEASE,ATTACH,DETACH, andVACUUMare rejected. - The only allowed PRAGMAs are
table_info,table_xinfo,index_info,index_xinfo,index_list,foreign_key_list,foreign_key_check,integrity_check,quick_check, anduser_version. - Names beginning with
__terse_or_litestream_are reserved.
@compute overrides the deployment's default resources for one actor class:
from durable_actors import Actor, compute, emitted, persisted
@compute(cpu=0.5, memory_mib=256, idle_timeout_ms=60_000)
class BankAccount(Actor):
balance: int = emitted(persisted(0))