What Is DBOS? Durable Python Workflows Stored in Postgres

DBOS is an open-source library for durable workflows. You annotate ordinary functions in a Python application (TypeScript, Go and Java are also supported), and DBOS records their progress in a database as they run. If the process dies, the work resumes from the last completed step when the process starts again. DBOS needs a Postgres database and nothing else.

That makes it unusually easy to describe. The state of a running program is a set of ordinary rows in Postgres, and recovery, queues, schedules and human-in-the-loop waits are all reads and writes against those rows. What follows covers what a durable workflow is, what DBOS is, how to use it in Python, how an AI agent fits the model, and what it costs.

What is a durable workflow?

A durable workflow is a function whose progress is recorded as it runs, so that a crash, a restart or a deployment interrupts it without losing its place. The function is divided into steps. Each completed step’s result is saved, and after a failure the workflow resumes from the first step with no saved result. Finished work is never repeated; interrupted work is retried.

The problem it addresses is ordinary. Any job that spans several calls to other systems (take a payment, reserve stock, send an email; or ask a model, call a tool, ask the model again) can fail between two of them and leave the job half done. Without a workflow runtime, the usual remedies are hand-written status columns, retry queues and clean-up scripts, rebuilt for each job. A durable workflow moves that bookkeeping into the runtime. Two further properties follow from the record. A workflow can wait for hours or days (on a timer, or for a human decision) without holding a process open, and its history is an audit trail of what ran and what each step returned.

Implementations differ mainly in where the record lives and what drives execution. Temporal and AWS Step Functions run a separate service that holds the history and dispatches work to your code. Azure Durable Functions and DBOS keep the history in storage the application writes to directly (Azure Storage by default in one case, Postgres in the other) and replay the function against it.

What is DBOS?

DBOS has two primitives. A workflow is a function decorated with @DBOS.workflow(). A step is a function decorated with @DBOS.step() and called from inside a workflow. When a workflow starts, DBOS writes its inputs to the database; each time a step completes, DBOS writes the step’s output; when the workflow returns, DBOS writes the result.

from dbos import DBOS

@DBOS.step()
def charge_card(order_id: str) -> str:
    ...  # call the payment provider, return a charge ID

@DBOS.step()
def send_receipt(order_id: str, charge_id: str) -> None:
    ...  # send an email

@DBOS.workflow()
def checkout(order_id: str) -> str:
    charge_id = charge_card(order_id)
    send_receipt(order_id, charge_id)
    return charge_id

Those writes land in a schema called dbos in what DBOS calls the system database. Three tables carry most of the weight. workflow_status holds one row per execution: its ID, the function name, a status (PENDING, ENQUEUED, SUCCESS, ERROR and a few others), the executor running it, its queue and its timings. workflow_input holds the serialised arguments. operation_outputs holds one row per completed step, keyed by the workflow ID and a function_id that numbers steps in the order they ran (from 1 in Python), with the step’s serialised output or error. The full schema is in the system database reference.

A checkout that dies after charging the card leaves a PENDING row in workflow_status, its argument in workflow_input, and one row in operation_outputs for charge_card. Nothing records a receipt. That is everything DBOS needs to finish the job.

Recovery reads those rows. On startup DBOS finds workflows still marked PENDING and calls each one again with its stored inputs. As the function re-executes, each step call first looks for its row in operation_outputs; if the row exists, the step returns the stored output without running. The first step with no row is where the original run stopped, and execution carries on normally from there. So charge_card returns the recorded charge ID, the customer is not charged twice, and send_receipt runs for the first time.

The other features use the same tables. A queued workflow is a workflow_status row with status ENQUEUED, which worker processes poll for under configurable concurrency and rate limits. A message sent to a running workflow is a row in dbos.notifications, and a value a workflow publishes is a row in dbos.workflow_events. Schedules have a table of their own. Since it is all SQL, a workflow’s history is one query away:

SELECT function_id,
       function_name,
       error IS NOT NULL AS failed,
       completed_at_epoch_ms - started_at_epoch_ms AS duration_ms
FROM dbos.operation_outputs
WHERE workflow_uuid = 'checkout-order-1042'
ORDER BY function_id;

(The output column holds Python values pickled by default, so it is readable from Python rather than from SQL. A portable JSON format exists for applications that mix languages.)

How do I use DBOS?

Install it with pip install dbos, then configure and launch it when the application starts:

import os
from dbos import DBOS, DBOSConfig

config: DBOSConfig = {
    "name": "orders",
    "system_database_url": os.environ.get("DBOS_SYSTEM_DATABASE_URL"),
}
DBOS(config=config)

if __name__ == "__main__":
    DBOS.launch()
    checkout("order-1042")

With no database URL DBOS uses a local SQLite file, which is enough for development. Anything with more than one process needs Postgres, and the URL is an ordinary postgresql:// connection string.

Replay puts two obligations on your code. The first is that a workflow function must be deterministic: given the same inputs and the same step outputs, it must call the same steps in the same order. Anything that could differ between runs (the time, a random number, a network call, a database read) belongs in a step, where its result is recorded once and returned from the record on every replay. Branching on a step’s output is safe for the same reason. DBOS raises DBOSStepNondeterminismError when it detects a replay that diverges.

The second is that steps should be safe to repeat. A step that completes is never run again, but a step interrupted part-way through will be retried, so DBOS guarantees each step runs at least once. Steps that call unreliable services can retry automatically with @DBOS.step(retries_allowed=True, max_attempts=5).

Replay also means the workflow function itself runs from the top on recovery. Code between steps (logging, building a prompt, assembling a list) executes again, which is harmless as long as it only depends on inputs and step outputs.

Every execution has a workflow ID, which is the key for all of its rows. By default DBOS generates a random UUID per execution (the ID is not derived from the inputs, so two calls with the same arguments are two executions). You can supply your own with SetWorkflowID, or with the workflow_id argument when enqueueing, as long as it is unique within the application. A supplied ID doubles as an idempotency key: run a workflow under an ID that already exists and DBOS returns the existing execution’s result instead of starting a second one. The useful IDs are built from something the job already has, such as an order number or an inbound request ID, so a retried request or a duplicated message lands on the same workflow:

from dbos import SetWorkflowID

with SetWorkflowID("checkout-order-1042"):
    checkout("order-1042")

(Pass workflow_id_reuse_policy="reject" to SetWorkflowID to raise an error on a duplicate instead.)

To re-run a recorded step, you re-execute the workflow from that step onward: the steps before it return their recorded outputs, and that step and everything after it run afresh. Re-running a single step in the middle while keeping the later results would hand those later steps inputs they never saw, so the rule follows from the replay model. There are two ways to do it. DBOS.fork_workflow(workflow_id, start_step) copies the inputs and the earlier steps into a new workflow with a new ID and runs it from start_step. DBOS.rewind_workflow(workflow_id, start_step) deletes the original workflow’s records from start_step onward and re-runs it under the same ID, which keeps messages, events and child workflows attached (it only works on a workflow that has finished, failed or been cancelled). Step numbers come from DBOS.list_workflow_steps(workflow_id) or dbos workflow steps on the command line. Both can target a newer application version, so a workflow that failed on a bug can be re-run from the faulty step once the fix is deployed. For an agent, the same mechanism re-runs a conversation from a given model call after a prompt change, without repeating the tool calls that came before it.

For work that should run in the background, DBOS.start_workflow returns a handle you can poll or wait on, and a Queue adds flow control:

from dbos import Queue

refunds = Queue("refunds", worker_concurrency=2)
handle = refunds.enqueue(checkout, "order-1042")
result = handle.get_result()

DBOS.sleep pauses a workflow durably: the wake-up time is stored, so a workflow told to sleep for a week still wakes on schedule through any number of restarts.

Example: an AI agent with DBOS

An agent maps onto DBOS directly. The agent loop is a workflow; each model call and each tool call is a step. The loop branches on whatever the model said, which looks like the kind of non-determinism the rules forbid. It is safe, because on replay the model’s reply comes back from operation_outputs and the model is not asked again. A crashed agent therefore resumes without paying twice for the calls it had already made.

Here is a refund agent with two tools, one of which waits for a human to approve it:

import json
from dbos import DBOS

@DBOS.step(retries_allowed=True, max_attempts=3)
def think(history: list[dict]) -> dict:
    reply = llm.chat.completions.create(model=MODEL, messages=history)
    return parse_action(reply.choices[0].message.content)  # raises on malformed output

@DBOS.step()
def lookup_order(order_id: str) -> dict:
    ...

@DBOS.step()
def issue_refund(order_id: str, amount: float) -> dict:
    ...  # keyed on order_id, so a retry overwrites the same record

@DBOS.workflow()
def refund_agent(request: str) -> str:
    history = [SYSTEM_PROMPT, {"role": "user", "content": request}]
    for _ in range(10):
        action = think(history)
        if action["action"] == "final_answer":
            return action["args"]["text"]
        if action["action"] == "issue_refund":
            DBOS.set_event("status", {"awaiting_approval": action["args"]})
            decision = DBOS.recv("approval", timeout_seconds=24 * 3600)
            if decision == "approve":
                result = issue_refund(**action["args"])
            else:
                result = {"error": "refund rejected by reviewer"}
        else:
            result = lookup_order(**action["args"])
        history.append({"role": "assistant", "content": json.dumps(action)})
        history.append({"role": "user", "content": json.dumps(result)})
    return "step limit reached"

Parsing and validating the model’s reply inside think matters. A malformed reply raises, DBOS retries the step, and only a valid action is ever recorded. A bad reply that reached the workflow would be replayed faithfully on every recovery.

The approval is where durability earns its keep. DBOS.set_event publishes the agent’s state for a dashboard or an inbox to read. DBOS.recv blocks until a message arrives on the approval topic or the deadline passes, in which case it returns None and the refund is refused. The deadline and the eventual message are both stored in Postgres, so the process can be restarted or redeployed while a reviewer takes a day to decide, and the workflow carries on waiting. The reviewer’s decision arrives from any other process with DBOS.send(workflow_id, "approve", topic="approval"), or through DBOSClient from a service that is not itself a DBOS application. It becomes a row in dbos.notifications.

The same pattern is packaged for several agent frameworks: DBOS has integrations for Pydantic AI, the OpenAI Agents SDK, LlamaIndex and Google ADK in Python, and the Vercel AI SDK in TypeScript, each of which turns model requests and tool calls into steps.

(If durable agents on your own infrastructure are a live problem for you, get in touch.)

What DBOS costs

Writes. DBOS adds one database write per step and two per workflow. DBOS’s own benchmarks put a single Postgres instance at over 40,000 workflows or steps per second, so for most applications the write count is a non-issue. Write size is the variable to watch, because it follows the size of what your steps return. A step that returns a generated image stores the image; the documented advice is to put large artefacts in object storage and return a reference.

Code changes. Recovery replays new code against old records. A deployment that changes which steps a workflow calls, or their order, can strand workflows that were in flight under the old code. DBOS handles this with patch markers in the code or with application versioning, so that old workflows only recover on processes running compatible code. Either way, deploying long-running workflows needs some forethought.

SQLAlchemy. DBOS is Postgres-native but not driver-neutral. Its own tables are reached through SQLAlchemy, and it migrates them itself on launch (or ahead of time with dbos migrate, for database roles without DDL rights). The same holds for application data, with larger consequences: exactly-once writes are only available through DBOS datasources, which hand you a SQLAlchemy session and add a tracking table to your database. Any other client, including a plain psycopg2 connection, runs as an ordinary step. It is retried on failure, so its idempotency is your problem.

Recovery across machines. A single process recovers its own workflows when it starts. With several workers, something has to notice that one has died and hand its workflows to another. DBOS provides this through Conductor, its control plane, or through recovery calls you make yourself. Conductor stays off the execution path (applications keep running if it is unreachable), but it is a separate service to account for.

What DBOS asks, then, is a small set of rules: deterministic workflow functions, steps that tolerate a retry, and some care when changing workflow code. In return, the state of every running program lives in a database you probably already operate, and you can inspect it, join it and repair it with SQL.


(Checklist: AI Readiness Assessment. Ten questions, five minutes. Find out where your organisation stands on AI (goals, data, sensitivity, ownership), with guidance on each answer.)

Questions about this? Get in touch.