How CocoIndex handles data and code changes

When a file, a function, a prompt or a table schema changes, CocoIndex re-runs only the work that depends on it. See what each change costs.

ConceptUpdated 8 min read

Only the work that depends on a change runs again

Recorded on 3 files and 9 paragraphs, CocoIndex 1.1.1 and Postgres 17.

  • ~ b.md edited1 component · 1 LLM call · 1 embedding call
  • ~ SUMMARY_PROMPT9 LLM calls · 0 embedding calls
  • ~ primary_keydrop, recreate, backfill 9 rows · 0 model calls
  • − c.md deleted2 rows deleted · 0 model calls

An index has two inputs that change. The source data changes: documents are added, edited and deleted. Your code changes too: you edit the chunker, a prompt, the embedding call or the columns of the output table. After each change, the index must match what the current code produces from the current data.

A full rebuild after every change gives a correct index, but it pays for every model call again. CocoIndex re-runs only the work that a change can affect. This post shows what that means for each kind of change, and then how CocoIndex decides what to re-run.

What does a change re-run?

The example pipeline indexes Markdown files into Postgres. Each file is a processing component. Each paragraph of a file gets an LLM summary and an embedding, and becomes one row of the doc_chunks table:

main.py
SUMMARY_PROMPT = "Summarize this passage in one sentence."

@dataclass
class DocChunk:
    id: int
    filename: str
    chunk_no: int
    text: str
    summary: str
    embedding: Annotated[NDArray, EMBEDDER]

@coco.fn(memo=True, version=1, deps=SUMMARY_PROMPT)
async def summarize(text: str) -> str:
    return await call_llm(SUMMARY_PROMPT, text)

@coco.fn(memo=True)
async def embed(text: str) -> NDArray:
    return await coco.use_context(EMBEDDER).embed(text)

@coco.fn
async def process_chunk(chunk_no, text, filename, id_gen, table) -> None:
    table.declare_row(
        row=DocChunk(
            id=await id_gen.next_id(text),
            filename=filename,
            chunk_no=chunk_no,
            text=text,
            summary=await summarize(text),
            embedding=await embed(text),
        )
    )

@coco.fn(memo=True)
async def process_file(file: FileLike, table: postgres.TableTarget[DocChunk]) -> None:
    text = await file.read_text()
    chunks = [p.strip() for p in text.split("\n\n") if p.strip()]
    id_gen = IdGenerator()
    for i, chunk in enumerate(chunks):
        await process_chunk(i, chunk, file.file_path.path.name, id_gen, table)

@coco.fn
async def app_main(sourcedir: pathlib.Path) -> None:
    table = await postgres.mount_table_target(
        PG_DB,
        table_name="doc_chunks",
        table_schema=await postgres.TableSchema.from_class(DocChunk, primary_key=["id"]),
    )
    files = localfs.walk_dir(
        sourcedir, path_matcher=PatternFilePathMatcher(included_patterns=["**/*.md"])
    )
    await coco.mount_each(process_file, files.items(), table)

The source folder holds 3 files with 9 paragraphs: a.md has 3, b.md has 4 and c.md has 2. Pick a change below to see what the next update re-runs. Each result is a real run on CocoIndex 1.1.1 against Postgres 17. The pipeline ran once, the change was applied, and the update ran again. The LLM and the embedding model were stubs that count each call.

What does this change re-run?

Recorded on CocoIndex 1.1.1 and Postgres 17
Data change
Code change
docs/b.md
removed: Orders over 200 dollars ship free inside the continental US.
added: Orders over 150 dollars ship free inside the continental US.
  • a.mdmemo hit, skipped

    0 LLM calls · 0 embedding calls · no row written

    a.md: memo hit, skipped. 0 LLM calls, 0 embedding calls. no row written.

  • b.mdran again

    1 LLM call · 1 embedding call · 1 replaced (new id: 1 delete, 1 insert)

    b.md: ran again. 1 LLM call, 1 embedding call. 1 replaced (new id: 1 delete, 1 insert).

  • c.mdmemo hit, skipped

    0 LLM calls · 0 embedding calls · no row written

    c.md: memo hit, skipped. 0 LLM calls, 0 embedding calls. no row written.

components ran
1 of 3
LLM calls
1 of 9
embedding calls
1 of 9
row writes
2
Postgres

No schema change.

1 row deleted, 1 row inserted.

Why: Only b.md's content changed, so only its component ran. 3 of its 4 paragraphs hit the summarize and embed memos. The edited paragraph got a new id, so its old row was deleted and a new one inserted.

Edit a paragraph in b.md: 1 of 3 components ran, 1 of 9 LLM calls, 1 of 9 embedding calls. Postgres: no schema change; 1 row deleted, 1 row inserted.

docs/
a.md
b.md
removed: c.md
  • a.mdmemo hit, skipped

    0 LLM calls · 0 embedding calls · no row written

    a.md: memo hit, skipped. 0 LLM calls, 0 embedding calls. no row written.

  • b.mdmemo hit, skipped

    0 LLM calls · 0 embedding calls · no row written

    b.md: memo hit, skipped. 0 LLM calls, 0 embedding calls. no row written.

  • c.mdcomponent removed

    2 rows deleted

    c.md: component removed. 2 rows deleted.

components ran
0 of 3
LLM calls
0 of 9
embedding calls
0 of 9
row writes
2
Postgres

No schema change.

2 rows deleted.

Why: c.md is no longer listed, so its component is not mounted and the 2 rows it declared are deleted without running any function.

Delete c.md: 0 of 3 components ran, 0 of 9 LLM calls, 0 of 9 embedding calls. Postgres: no schema change; 2 rows deleted.

main.py
    summary: str
added:     char_count: int
…
added:             char_count=len(text),
  • a.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 3 rows updated

    a.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 3 rows updated.

  • b.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 4 rows updated

    b.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 4 rows updated.

  • c.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 2 rows updated

    c.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 2 rows updated.

components ran
3 of 3
LLM calls
0 of 9
embedding calls
0 of 9
row writes
9
Postgres
ALTER TABLE "doc_chunks" ADD COLUMN "char_count" bigint;

9 rows updated.

Why: process_chunk changed to fill the field, and its fingerprint reaches every process_file memo, so every file ran. summarize and embed got the same text and hit. A new column is an in-place ALTER.

Add a column to DocChunk: 3 of 3 components ran, 0 of 9 LLM calls, 0 of 9 embedding calls. Postgres: ALTER TABLE "doc_chunks" ADD COLUMN "char_count" bigint; 9 rows updated.

main.py
removed: primary_key=["id"]
added: primary_key=["filename", "chunk_no"]
  • a.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 3 rows backfilled

    a.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 3 rows backfilled.

  • b.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 4 rows backfilled

    b.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 4 rows backfilled.

  • c.mdran, all memos hit

    0 LLM calls · 0 embedding calls · 2 rows backfilled

    c.md: ran, all memos hit. 0 LLM calls, 0 embedding calls. 2 rows backfilled.

components ran
3 of 3
LLM calls
0 of 9
embedding calls
0 of 9
row writes
9
Postgres
DROP TABLE IF EXISTS "doc_chunks";
CREATE TABLE "doc_chunks" (…, PRIMARY KEY ("filename", "chunk_no"));

9 rows inserted into the new table.

Why: A primary key change cannot be applied in place, so the table is dropped and created again. Every component that wrote to it runs again to backfill the rows, but summarize and embed write no rows, so their memos still hit.

Change the primary key: 3 of 3 components ran, 0 of 9 LLM calls, 0 of 9 embedding calls. Postgres: DROP TABLE IF EXISTS "doc_chunks"; CREATE TABLE "doc_chunks" (…, PRIMARY KEY ("filename", "chunk_no")); 9 rows inserted into the new table.

main.py
removed: return await coco.use_context(EMBEDDER).embed(text)
added: return await coco.use_context(EMBEDDER).embed(f"passage: {text}")
  • a.mdran again

    0 LLM calls · 3 embedding calls · 3 rows updated

    a.md: ran again. 0 LLM calls, 3 embedding calls. 3 rows updated.

  • b.mdran again

    0 LLM calls · 4 embedding calls · 4 rows updated

    b.md: ran again. 0 LLM calls, 4 embedding calls. 4 rows updated.

  • c.mdran again

    0 LLM calls · 2 embedding calls · 2 rows updated

    c.md: ran again. 0 LLM calls, 2 embedding calls. 2 rows updated.

components ran
3 of 3
LLM calls
0 of 9
embedding calls
9 of 9
row writes
9
Postgres

No schema change.

9 rows updated.

Why: The edit changes embed's logic fingerprint. Every memoized call that reached embed is invalid: all 3 components and all 9 embedding calls. summarize is not affected.

Edit the body of embed: 3 of 3 components ran, 0 of 9 LLM calls, 9 of 9 embedding calls. Postgres: no schema change; 9 rows updated.

main.py
removed: SUMMARY_PROMPT = "Summarize this passage in one sentence."
added: SUMMARY_PROMPT = "Summarize this passage for a support agent."
  • a.mdran again

    3 LLM calls · 0 embedding calls · 3 rows updated

    a.md: ran again. 3 LLM calls, 0 embedding calls. 3 rows updated.

  • b.mdran again

    4 LLM calls · 0 embedding calls · 4 rows updated

    b.md: ran again. 4 LLM calls, 0 embedding calls. 4 rows updated.

  • c.mdran again

    2 LLM calls · 0 embedding calls · 2 rows updated

    c.md: ran again. 2 LLM calls, 0 embedding calls. 2 rows updated.

components ran
3 of 3
LLM calls
9 of 9
embedding calls
0 of 9
row writes
9
Postgres

No schema change.

9 rows updated.

Why: SUMMARY_PROMPT is in summarize's deps, so the edit is a logic change to summarize: 9 LLM calls. The embeddings depend only on the text, so all 9 embedding calls hit.

Change the summary prompt (deps): 3 of 3 components ran, 9 of 9 LLM calls, 0 of 9 embedding calls. Postgres: no schema change; 9 rows updated.

main.py
removed: @coco.fn(memo=True, version=1, deps=SUMMARY_PROMPT)
added: @coco.fn(memo=True, version=2, deps=SUMMARY_PROMPT)
  • a.mdran again

    3 LLM calls · 0 embedding calls · 3 rows updated

    a.md: ran again. 3 LLM calls, 0 embedding calls. 3 rows updated.

  • b.mdran again

    4 LLM calls · 0 embedding calls · 4 rows updated

    b.md: ran again. 4 LLM calls, 0 embedding calls. 4 rows updated.

  • c.mdran again

    2 LLM calls · 0 embedding calls · 2 rows updated

    c.md: ran again. 2 LLM calls, 0 embedding calls. 2 rows updated.

components ran
3 of 3
LLM calls
9 of 9
embedding calls
0 of 9
row writes
9
Postgres

No schema change.

9 rows updated.

Why: With version set, the integer replaces summarize's source in its fingerprint. Bump it when behavior changes where CocoIndex cannot see it, here a new model behind call_llm. Without the bump, that model change re-ran nothing.

Bump summarize's version: 3 of 3 components ran, 9 of 9 LLM calls, 0 of 9 embedding calls. Postgres: no schema change; 9 rows updated.

A rebuild from scratch (cocoindex update --reset) makes 9 LLM calls and 9 embedding calls and inserts all 9 rows (7 after deleting c.md). The model-call count measures the cost of a change; the row count measures what a search or an agent reading the table sees change.

CocoIndex fingerprints both data and code

A data change alters the input of a processing component. A code change alters the logic of a function. CocoIndex fingerprints both, and the fingerprints decide which memoized work is still valid. A spreadsheet works the same way: edit a cell or a formula, and only the cells that depend on it recompute.

Data changes re-run only the changed item

Before the update that edits b.md, the recording rewrote all 3 files, so every file had a new modification time, but only b.md ran again. A source connector lists items, and mount_each mounts one component per item at a stable path, here the file path. On a data change, a memoized component runs again only when its input fingerprint changes. For local files, CocoIndex compares the modification time first, and reads the content for a hash only when the time differs (memoization keys).

Inside a component that runs again, each memoized function still hits for arguments it has seen, as long as its logic fingerprint is unchanged. In the edit to b.md, 3 of the 4 paragraphs hit the summarize and embed memos, so the update made 1 LLM call and 1 embedding call.

When an item disappears from the source, its component is not mounted on the next run. The rows it declared are no longer declared, so CocoIndex deletes them without running any function.

Code changes re-run the calls that reached them

When a memoized call hits, its body does not run, and neither do the functions it calls. CocoIndex returns the stored value and keeps the rows the call declared last time. Whether it hits depends on the logic fingerprint of each @coco.fn function (function docs):

  • Source. The fingerprint is the function’s source, parsed to an AST. Comments, docstrings, formatting and decorators are not part of it. In the recording, a new comment and docstring in embed re-ran nothing.
  • Propagation. The fingerprint of every @coco.fn function that runs during a memoized call is part of that call’s memo. The chain is built from the calls that ran during that execution, so a function that was not called adds nothing. An edit to embed invalidates every memoized call that reached embed: here, every process_file component and every embed call. A helper without @coco.fn passes the chain through, but its own source is not tracked. Edits to it invalidate nothing, so decorate helpers whose output matters.
  • deps=. Values the function reads that are not in its source, such as a prompt or a model id defined at module level. A change to the value is a logic change. CocoIndex reads the value once, when the decorator runs.
  • version=. The integer replaces the source in the fingerprint. An edit to the body re-runs nothing until you bump the number. Use it when behavior changes where CocoIndex cannot see it. In the recording, call_llm is a plain helper. When the model behind it changed, nothing re-ran; after version=2, all 9 summaries ran again.

To stop propagation at a function, set logic_tracking="self" (track only its own logic) or logic_tracking=None. Values that come from context are tracked only when their ContextKey has detect_change=True. The EMBEDDER key in this example does not set it, so swapping the embedder there would re-run nothing; pass detect_change=True when a different model should re-embed.

The table schema is code too

An edit to the row class or to the primary key is a change to the table’s declared state, and the update applies it like any other change. The table’s columns come from the row class. postgres.TableSchema.from_class(DocChunk, primary_key=["id"]) maps each field to a Postgres type (Postgres connector). The vector column’s dimension comes from the Annotated metadata: EMBEDDER is a context key whose value is a VectorSchemaProvider, so the column gets the embedder’s size, for example vector(384). The connector creates the pgvector extension when a table needs it. To override a mapping, pass column_overrides.

How CocoIndex applies an update

Declared state and previous state

A target state is something that should exist in an external system: a table, a row, a file. Your code declares target states on each run. CocoIndex records them, with the memoized results, in its internal storage: an LMDB database at the path in COCOINDEX_DB. On the next run, it compares what you declare now with what it recorded and applies only the difference:

Target stateOn first declarationWhen declared differentlyWhen no longer declared
A database tableCreate the tableAlter the tableDrop the table
A row in a tableInsert the rowUpdate the rowDelete the row
A file in a directoryCreate the fileUpdate the fileDelete the file

Both kinds of change reach the target through this comparison. A row whose declared value is unchanged is not written: when the edit to b.md ran its component again, its 3 unchanged rows were not touched.

Schema changes alter in place or backfill

You do not need a full reprocess after a schema change. The connector reports whether a change destroyed all rows or may have lost some, and CocoIndex uses that report to decide which components to run again (child invalidation). The Postgres connector alters the table in place when it can:

  • New column: ALTER TABLE ... ADD COLUMN. Existing rows stay. In the recording, every component still ran again, because process_chunk changed to fill the new field.
  • Changed column type or nullability: ALTER COLUMN ... TYPE. If Postgres cannot convert the data, the connector drops and re-adds the column, and that column’s data is lost. Either way the change counts as lossy, so CocoIndex runs the components that wrote to the table again.
  • Removed column: ALTER TABLE ... DROP COLUMN. CocoIndex runs the components that wrote to the table again.
  • New primary key: the table cannot be altered in place, so CocoIndex drops it, recreates it and re-runs every component that wrote to it to backfill the rows. The model calls inside those components still hit their memos: the recording made 0 model calls for this change.

CLI commands that reset or reprocess

You can also trigger these transitions from the CLI:

  • cocoindex update runs the app once in catch-up mode and applies the comparison above.
  • --reset drops the existing setup before the update, the same as running cocoindex drop first.
  • --full-reprocess bypasses every memo, so every component and memoized function runs again, model calls included. To re-run one function’s work, bump its version instead.
  • cocoindex drop reverts every target state the app created (it drops tables and deletes rows) and clears the app’s internal state.

Live mode

In catch-up mode, each update scans the sources to find what changed, and changes appear only when you run it. Live mode (cocoindex update -L) keeps the app running after the first catch-up. Sources that can report changes, such as a filesystem watcher, a database change feed or a Kafka consumer, then send each change into the same comparison. For the pipeline above, pass live=True to localfs.walk_dir. A new or edited item runs its component again; a deleted item removes the component and its target states.

Run the recording

The pipeline and the script that recorded the demo are in the post’s _data-src folder. The script starts each change from an empty table, applies it, runs the update and reads the Postgres statement log. For a complete app with a real embedding model, start from the text embedding example. The targets CocoIndex supports are listed in the connectors overview.

Frequently asked questions

What does CocoIndex re-run when I edit a prompt?

Put the prompt in the function's deps, as in @coco.fn(memo=True, deps=SUMMARY_PROMPT). An edit to the prompt is then a logic change to that function: every memoized call to it runs again, and so does every memoized caller that reached it. Other memoized functions still hit. In the post's recorded example, a prompt edit made 9 LLM calls and 0 embedding calls. See What does a change re-run?.

How does CocoIndex detect a code change?

Each @coco.fn function has a logic fingerprint, computed from its source parsed to an AST, so comments, docstrings and formatting do not count. The fingerprint of every @coco.fn function that runs during a memoized call is part of that call's memo, so an edit to a function invalidates only the memoized calls that reached it. A helper without @coco.fn is not tracked. See Code changes re-run the calls that reached them.

When should I use version= instead of deps= in CocoIndex?

Use deps for a value the function reads that is not in its source, such as a prompt or a model id: a change to the value invalidates the memo without a version bump. Use version when behavior changes where CocoIndex cannot see it, such as a new model behind the same API. The integer replaces the source in the fingerprint, so an edit to the body re-runs nothing until you bump it. See Code changes re-run the calls that reached them.

Where does CocoIndex get the schema of the target table?

From your row class. postgres.TableSchema.from_class(DocChunk, primary_key=["id"]) maps each field to a Postgres type, and a VectorSchemaProvider in the Annotated metadata of a vector field, such as an embedder, gives the vector dimension. See The table schema is code too.

What happens when I change a table's primary key or columns?

A new column is added in place with ALTER TABLE ... ADD COLUMN, and existing rows stay. A primary key change cannot be applied in place, so CocoIndex drops and recreates the table and runs the components that wrote to it again to backfill the rows. Memoized model calls inside those components still hit: the recorded example made 0 model calls for a primary key change. See Schema changes alter in place or backfill.

Where does CocoIndex keep its state between runs?

In an LMDB database at the path in COCOINDEX_DB. It holds the target states each run declared and the memoized results, so the next run can compare and apply only the difference. See Declared state and previous state.

Build fresh context for your agents.

CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.