A CrewAI Flow is a pipeline: a chain of methods that call agents, transform results, and hand off to the next step. The demo always works. Then one runs in production, the API times out at step four of six, the process exits — and the next run starts from step one, re-paying for every LLM call in steps one through three. That's not a bug. That's the default. A Flow is stateless across process restarts unless you tell it otherwise, and the thing that tells it otherwise is a single decorator: @persist.

This is a copy-paste walkthrough: build a small Flow, make it durable, crash it on purpose, resume it, and then swap the default SQLite store for your own database. If you want the conceptual map of Flows vs Crews first, read when to script agents and when to let them decide — this piece assumes you've picked a Flow and now need it to survive contact with production.

The takeaway, in code#

@persist snapshots self.state after each step. Re-run with the same id, and completed steps are skipped. That's the whole idea. Everything below is detail.

1. A Flow with real state#

State is the thing you're persisting, so it has to be structured. A Flow carries a Pydantic model on self.state — not a loose dict — and that typing is what makes a checkpoint reloadable.

from crewai.flow.flow import Flow, start, listen
from pydantic import BaseModel

class ResearchState(BaseModel):
    topic: str = ""
    outline: str = ""
    draft: str = ""

class ArticleFlow(Flow[ResearchState]):

    @start()
    def make_outline(self):
        # imagine an expensive crew.kickoff() here
        self.state.outline = f"Outline for: {self.state.topic}"
        return "outlined"

    @listen(make_outline)
    def write_draft(self):
        self.state.draft = f"Draft from {self.state.outline}"
        return "drafted"

Run this and it works, but nothing is saved. Kill the process between make_outline and write_draft and both steps re-run next time. The outline you already generated — and paid a model to produce — is gone.

2. Add one decorator#

Put @persist on the class. That's the change.

from crewai.flow.persistence import persist

@persist
class ArticleFlow(Flow[ResearchState]):
    ...  # methods unchanged

With no arguments, @persist uses SQLiteFlowPersistence — a local SQLite database, created for you, no configuration. After each decorated step returns, CrewAI writes the current self.state to that database. You now have a checkpoint after every step, automatically.

The feature you were about to build yourself — snapshot state, key it on a run id, reload on restart — is one decorator. The engineering left for you is picking the right id.

3. The one thing you must get right: the id#

Here's where people lose an afternoon. Persistence is keyed on the Flow's id. Every Flow has an id field; if you don't set it, CrewAI generates a fresh UUID on every run. A fresh id every run means every run is a brand-new record and nothing ever resumes@persist is dutifully saving state to a key you'll never look up again.

To make resume work, pass a stable id you control — a job id, an order id, a document id, anything unique to this unit of work:

flow = ArticleFlow()
flow.kickoff(inputs={"id": "article-4821", "topic": "stateless MCP"})

Run that once and let it finish make_outline, then crash before write_draft (next section shows how). Run the exact same line again. Because the id matches, CrewAI rehydrates self.state from the checkpoint — outline is already populated — and execution resumes at write_draft. The outline step does not re-run. You did not pay for it twice.

Get the id wrong and everything else is correct and useless. This is the load-bearing line.

4. Prove it: crash, then resume#

To see resume actually work, force a failure between the two steps:

class ArticleFlow(Flow[ResearchState]):

    @start()
    def make_outline(self):
        self.state.outline = f"Outline for: {self.state.topic}"
        print("outline saved:", self.state.outline)
        return "outlined"

    @listen(make_outline)
    def write_draft(self):
        if not self.state.draft:            # simulate a crash on first pass
            raise RuntimeError("boom — API timeout")
        self.state.draft = f"Draft from {self.state.outline}"
        return "drafted"

The first kickoff(inputs={"id": "article-4821", ...}) prints the outline, then raises. State-after-make_outline is already on disk. Remove the raise (or let the transient error clear) and run the same kickoff line again: the outline is loaded from SQLite, make_outline is skipped, and write_draft completes. That's crash recovery in about forty lines.

5. Ship it: swap SQLite for your own store#

SQLite on local disk is fine for a laptop and wrong for a fleet of stateless workers that don't share a filesystem. For production you replace the backend by implementing the FlowPersistence interface — three methods — and handing an instance to the decorator:

from crewai.flow.persistence.base import FlowPersistence

class PostgresPersistence(FlowPersistence):
    def init_db(self):
        ...   # create the table if needed

    def save_state(self, flow_uuid, method_name, state_data):
        ...   # upsert (flow_uuid, method_name) -> state_data

    def load_state(self, flow_uuid):
        ...   # return the latest state dict for this id, or None

@persist(PostgresPersistence())
class ArticleFlow(Flow[ResearchState]):
    ...

Point save_state/load_state at Postgres, Redis, DynamoDB — whatever your platform already runs — and every worker in the fleet can resume any run, because the checkpoint lives in shared storage instead of one machine's disk. This is the same move CrewAI made across its whole storage layer: a thin orchestration core over a backend you own.

Don't persist the wrong thing#

One clarification that saves confusion later. @persist durably stores Flow state — the orchestration spine of a single run, so it can resume. It is not crew memory. Crew long-term memory — an agent recalling facts from past runs — is a separate subsystem, backed by LanceDB on disk by default, and it answers a different question ("what does this agent know?") than @persist does ("where was this run when it died?").

You frequently want both: @persist so a crashed pipeline resumes, and crew memory so the agents inside it remember. Just don't reach for one when you need the other. If your run won't resume, it's a Flow-state problem — check the id. If your agent forgot something it should recall, it's a memory problem — check the memory backend.

The decision, made plainly#

A multi-agent pipeline that can't survive a restart isn't production software; it's a demo that hasn't failed yet. One decorator and a stable id move it across that line — and if you're on LangGraph instead of CrewAI, the checkpointer-and-thread_id version of this exact playbook gets you there the same way.