Best practices
Patterns and anti-patterns for workflow design, task design, agents, operations, and testing — what holds up after Flux has been in production.
Curated patterns that fall out of running Flux in production. Each entry is a one-line rule, two or three lines of why, and a pointer to the canonical page when there is one. Anti-patterns are called out explicitly at the end.
Workflow design
One input parameter, always
A workflow takes exactly one argument after ctx: a primitive, a dict, or a Pydantic model. Wrap multi-field inputs in a Pydantic model so the catalog can publish the JSON schema and the REST API can validate calls before the workflow ever starts. See Defining workflows and Workflow inputs.
Namespace early
Set a namespace on every non-trivial workflow via @workflow.with_options(namespace="billing"). RBAC permissions, schedule history, and execution lists all key off namespace + name; renaming after the fact is cheap but reorganizing permissions later is not. See Namespaces.
Small workflows compose better than large ones
A workflow that runs ten tasks in sequence is easier to replay, easier to pause-resume, and easier to retry than a workflow that runs one task with ten branches inside it. The replay surface is the event log — fewer events per workflow means faster replays and cleaner failure boundaries. Use call_workflow when one workflow needs to invoke another.
Pin workflow versions for long-running schedules
Flux versions workflows on registration; the scheduler dispatches whatever version was current when the schedule was created. For schedules that run for weeks or months, register the workflow explicitly and reference its version in operational runbooks. See Workflow versions.
Avoid mutable state in the workflow body
The workflow body re-runs from the top on replay; every line outside an await task(...) call runs again on every replay. Computing a list, mutating a dict, reading the current time — all of it happens twice if the workflow pauses and resumes. Push computation into tasks; keep the workflow body to control flow only. See Determinism.
Task design
Keep task bodies short
A task should do one thing — call one external API, run one database query, transform one record. Tasks are the unit of retry, the unit of caching, and the unit of resumability. A 200-line task is a 200-line retry surface. See Defining tasks.
One external service per task
If a task calls an HTTP API and a database, split it. Retries, timeouts, and idempotency requirements differ per service; mixing them inside one task means choosing the strictest setting and over-paying everywhere else. See External services.
Idempotency is your responsibility
Flux retries failed tasks; the task itself decides whether the retry is safe. Use a deterministic request ID for HTTP calls, an upsert for database writes, a conditional create for resources. Flux will not deduplicate side effects for you. See Idempotency.
Cache pure functions only
@task.with_options(cache=True) writes the return value to disk and returns it on every subsequent call with the same inputs — within and across executions. The cache covers the return value only; side effects inside the function body are not rolled back or suppressed on a hit. Cache lookups, embeddings, and CPU work — never cache anything that emails, charges, or writes to a third party. See Caching task results.
Use output_storage for large results
Task outputs travel through the event log; a 50 MB DataFrame as a task return value is a 50 MB event row. Set output_storage=LocalFileStorage() (or another backend) on tasks whose results are big and the event log keeps a pointer instead. See Output storage.
Agent design
Default to Anthropic in production, Ollama in development
Anthropic is the production default for Flux agents — it’s the provider with the deepest tool-use semantics in Flux 0.56.0 and the one the agent harness exercises most often. Ollama is the development default because it’s free and offline. Swap providers via the agent definition; the harness is provider-agnostic. See Choosing a model.
Set max_tool_calls
Agents that loop call-tool, observe, call-tool can run unbounded if a model gets stuck. Set max_tool_calls on every production agent — the harness raises when the cap is hit, which is a much better failure mode than the loop running until a timeout or a budget alert. See Agent options.
Use requires_approval for side-effectful tasks
Any task that sends an email, charges a card, deploys code, or makes an irreversible change should declare @task.with_options(requires_approval=True) (or a predicate that gates only the high-risk calls). The workflow pauses on the gated call; an operator approves or rejects via flux execution approve|reject or the equivalent HTTP endpoint. The same primitive gates agent tool calls — see Tool approval — and ordinary workflow steps — see Human approvals.
Prefer response_format for structured agent output
response_format=MyPydanticModel constrains an agent to return a typed object instead of free-form text. Anthropic, OpenAI, and Gemini all enforce the schema at the API — Anthropic does it through a forced tool call. Ollama is the exception: it drops response_format when tools are also configured and logs a warning when it does. See Structured output.
Operations
Always set the bootstrap token
flux start server no longer ships a default bootstrap token. Set FLUX_WORKERS__BOOTSTRAP_TOKEN (or [flux.workers] bootstrap_token in flux.toml) before the server starts; otherwise the server raises on first worker registration. See Bootstrap token.
Use PostgreSQL — and sticky routing — before scaling the server
Server replicas coordinate through PostgreSQL: the scheduler cycle and retention sweep are fleet-wide singletons via advisory locks, and dispatch claims with SKIP LOCKED, so multiple replicas are safe and crons fire once. The two prerequisites people miss: SQLite is single-node only, and a worker’s SSE stream lives on the replica it connected to, so the load balancer needs source-IP or cookie affinity for worker connections. See High availability.
Run flux db upgrade before rolling replicas
The schema is Alembic-managed and auto-migrates on connect (advisory-lock-guarded), but a deploy pipeline that runs flux db upgrade explicitly before rolling server replicas keeps old and new versions from disagreeing mid-roll and makes the migration a visible, logged step. See Upgrades and migrations.
Enable retention before the event tables force you to
[flux.retention] enabled defaults to false, and every task is a persisted event row — an unpruned production database only grows. Turn it on with a retention_days that matches your audit window on day one, not after the first slow dashboard. See Retention.
Enable observability before going live
Turn on [flux.observability] enabled = true and wire the OpenTelemetry exporter to your tracing backend on day one. Diagnosing a workflow stuck in SCHEDULED without trace data is much harder than reading the span tree. See Observability.
Cgroup workers at the host level
Flux doesn’t enforce CPU or memory limits on workers — requests=ResourceRequest(cpu=2, memory="512Mi") is matching metadata, not an enforcement mechanism. If you need hard limits, run workers under Docker, Kubernetes, or systemd with explicit cgroup constraints. See Worker resources.
Testing
Use .run() for inline tests
workflow.run(input) executes the workflow in-process against SQLite — no server, no worker, no network. Use it for unit tests that exercise workflow logic. Pair it with assert ctx.has_finished and ctx.has_succeeded to verify the workflow reached terminal success. See Inline execution.
Keep integration tests against a running server
Inline tests don’t exercise the SSE dispatch path, claim/checkpoint protocol, or schedule trigger. A small pytest fixture that spawns flux start server and flux start worker as subprocesses and registers your workflows catches the dispatch-side bugs that inline can’t. The tests/e2e/ directory in the Flux repository is a working pattern.
Test idempotency explicitly
Write a test that runs the task twice with the same inputs and asserts the side effect happened once. If you can’t write that test, the task isn’t idempotent yet. See Idempotency.
Anti-patterns
Don’t call time.time() in the workflow body
The workflow body replays on every resume; time.time() returns a different value each time and breaks determinism. Use await now() from flux.tasks — it captures the timestamp into the event log on first run and returns the same value on every replay. See Determinism and Built-in tasks.
Don’t put side effects in cached tasks
A cached task with cache=True skips the function body on a hit. If the body sends an email, the email goes out once and never again — usually not what you want. Keep side effects in a separate non-cached task that the cached task feeds into. See Caching task results.
Don’t print() secrets
Anything written to stdout lands in the worker log, which lands wherever you’re shipping logs. Read secrets from secret_requests, use them, don’t log them. See Secrets.
Don’t scale server replicas on SQLite
The most common multi-replica misconfiguration: pointing two servers (or a server plus more than one worker) at a SQLite file. The SKIP LOCKED claim safety and advisory-lock coordination that make multi-node deployments safe are silently unenforced on SQLite — the server logs a warning when a second worker registers, and it means move to PostgreSQL, not tune SQLite. Covered above under operations.
Don’t assume flux start server provides TLS
flux start server listens on plain HTTP by default. Production deployments terminate TLS at a reverse proxy (nginx, Caddy, an ALB, an ingress controller) and forward to Flux over the loopback interface. There is no built-in TLS option in 0.56.0 — and TLS in front of every replica is a hard requirement, since bearer tokens, worker session keys, and decrypted secrets travel over this channel. See Production deployment.