Feature Feature Custom Source Connectors Data Indexing Architecture ~6 min read

Bring your own data: Index any data with Custom Sources

Read data from any system with CocoIndex: in v1 a custom source is plain async Python plus processing components. No connector API to implement.


Updated Jul 9, 2026
Bring your own data: Index any data with Custom Sources

CocoIndex can read data from any system: internal APIs, databases, file systems, cloud storage, legacy services. You are not limited to the built-in connector library. Whatever you can fetch from Python, CocoIndex can ingest incrementally, track for changes, and keep in sync with your targets.

Updated for CocoIndex v1

The original version of this post introduced a dedicated source connector API (SourceSpec, source_connector, list() / get_value()). CocoIndex v1 removed the need for that interface: a custom source is now ordinary async Python plus processing components. This post describes the v1 approach, with a mapping from the v0 API for readers of the original.

Custom sources are the complement to custom targets, giving you control over both ends of a pipeline: where data comes from and where it lands.

Why not build another thousand connectors?

We could, and expanding the connector library is on the roadmap. But more connectors alone don’t solve the problem. Enterprise data is siloed behind complex systems, inconsistent schemas, and fragile integrations. What pipelines actually need is infrastructure that handles ever-changing datasets, reconciles differences across systems, and keeps data flows durable and observable. That is where CocoIndex focuses: the incremental processing engine underneath, with sources as a thin layer you can supply yourself.

Assemble pipelines from building blocks

CocoIndex apps are modular: sources, transformations, and targets compose, and each block swaps in a single line of code. Custom sources are the input-side block you supply yourself, alongside:

  • Custom targets: write to any system, with the same diff-and-sync treatment as built-in targets.
  • Custom transformations: domain-specific logic as ordinary decorated functions.
  • Built-in connectors and operations for the common cases.

Mix and match to build end-to-end pipelines that stay incremental and traceable, whatever they read from or write to.

CocoIndex building blocks: a sources column (local files, cloud storage, postgres, your custom source), a transform group (parse, chunk, extract structure, embed, dedup, custom logic), and a targets column (relational, vector, and graph databases, your custom target), all running on the incremental engine.

What a custom source looks like in v1

A custom source is two ordinary async functions and one processing component: no decorators on the fetch code, connector class, or plugin registration.

  1. A listing function that returns what exists right now, as stable keys.
  2. A fetch function that returns the full content for one key.
  3. A processing component, one per item, that transforms the content and declares the rows that should exist downstream.

Here is the shape, using an internal ticketing API as the example:

python
import cocoindex as coco
from cocoindex.connectors import postgres


@dataclass
class TicketRow:
    id: str
    title: str
    body: str
    updated_at: datetime


async def list_ticket_ids() -> list[str]:
    """Ask your system what exists right now."""
    ...


async def fetch_ticket(ticket_id: str) -> TicketRow:
    """Fetch the full content for one item."""
    ...


@coco.fn
async def process_ticket(
    ticket_id: str, table: postgres.TableTarget[TicketRow]
) -> None:
    ticket = await fetch_ticket(ticket_id)
    table.declare_row(row=ticket)


@coco.fn
async def app_main() -> None:
    table = await postgres.mount_table_target(
        PG_DB,
        table_name="tickets",
        table_schema=await postgres.TableSchema.from_class(
            TicketRow, primary_key=["id"]
        ),
    )
    ticket_ids = await list_ticket_ids()
    await coco.mount_each(process_ticket, ((t, t) for t in ticket_ids), table)

A custom source in CocoIndex v1: your system is read by plain Python fetch functions, mount_each fans out one processing component per item, each component declares target rows, and the CocoIndex engine diffs, syncs, cleans up, and memoizes underneath.

Three details carry the weight here:

  • coco.mount_each fans out one processing component per item, keyed by the ID you pass. That key gives each item a stable component path across runs, which is how the engine matches an item to its previous run.
  • declare_row states what should exist in the target. You never write inserts, updates, or deletes. The engine compares the declared rows against the previous run and applies only the difference.
  • The fetch functions are plain async def. Any HTTP API, message queue, SDK, or database driver works the same way. Authentication and connection pooling live where they normally would in Python: create the client in the app lifespan and share it through a context key.

The downstream half of the pipeline (chunking, embedding, LLM extraction, graph building) is written exactly the same way as for a built-in connector. A transformation doesn’t know or care whether its input came from S3, Postgres, or your ticketing API.

How it stays incremental

The engine never assumes it must reprocess everything. On each run:

  • Items whose key is new get processed and their rows created.
  • Items already seen are matched by component path. Unchanged declared rows produce no writes; changed rows produce exactly the update needed.
  • Items that disappear from your listing have their target states cleaned up automatically. If a ticket is deleted upstream, its rows go away downstream without any delete logic on your side.

Every @coco.fn participates in change detection: the engine fingerprints inputs and logic, so it knows when a function’s result may have changed, including when you edit the code. For expensive steps such as embedding or LLM extraction, add memo=True and the memoized result is reused whenever inputs and logic are unchanged. Re-running a pipeline over a mostly-unchanged source only pays for what actually changed.

In v0 this efficiency depended on your connector exposing an ordinal (a timestamp or version number) so the engine could skip unchanged items. In v1 the engine fingerprints and diffs on its own, so any source gets incremental behavior without cooperating. To keep a source fresh over time, run cocoindex update on a schedule; sources with native change watching plug into live mode.

From the v0 connector API

If you read the original version of this post, or wrote a v0 custom source, here is where each piece went:

v0 connector APIv1 equivalent
SourceSpecPlain function arguments and app config
create(spec)Your own client setup, typically in the app lifespan
list()A listing function plus the keys passed to mount_each
get_value(key)A fetch call inside the per-item component
Key and value typesThe component key, plus dataclasses for rows
provides_ordinal()Not needed: change detection and memoization

The v0 interface asked you to fit your system into a connector contract so the engine could read through it. The v1 model inverts that: you read your system with whatever code is natural, and the engine tracks the results. Less to implement, and nothing to learn beyond the concepts the rest of the framework already uses.

When to build a custom source

Reach for a custom source when there is genuinely no built-in connector for where your data lives: an internal or proprietary API, a legacy database with an unusual access path, or a SaaS product CocoIndex doesn’t ship a connector for yet. If a built-in connector already covers your system, use it. The value of the custom-source model is the long tail of your systems, brought in without forking or patching the engine.

Common shapes we see:

  • Knowledge aggregation for LLM context: wikis, ticket systems, and internal APIs each become a fetch function and a component, and the index stays current as they change.
  • Data stitching: one component fetches from several services (auth, billing, usage) and declares a single composite row, so the join happens before the data hits your index.
  • Legacy wrappers: wrap a painful read path (SOAP, XML, mainframe exports) in a fetch function and get a modern, queryable table on the other side.

See it in action

The HackerNews example is a complete custom source built this way: it fetches recent stories and their nested comments from the Algolia HN API, extracts topics from every message with an LLM, and keeps two Postgres tables in sync for search and analytics.

The custom-source walkthrough builds it step by step, and the example gallery has more pipelines to start from.

If you build a source for a system others use too, we’d like to hear about it: CocoIndex on GitHub.

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

What is a custom source in CocoIndex?

A custom source is how CocoIndex reads data from a system with no built-in connector. In CocoIndex v1 it is ordinary async Python: a listing function that returns what exists right now as stable keys, a fetch function that returns the full content for one key, and a processing component per item that declares the rows that should exist downstream. There is no connector interface to implement.

See What a custom source looks like in v1.

Do I need to implement a connector API to add a custom source?

No. The v0 connector interface (SourceSpec, source_connector, list() / get_value()) was removed in CocoIndex v1. You fetch data with plain async def functions, fan out one processing component per item with coco.mount_each, and declare target rows with declare_row. The engine handles change detection, syncing, and cleanup.

See From the v0 connector API.

Why doesn't CocoIndex just build more prebuilt connectors?

Expanding the connector library is on the roadmap, but connectors alone don't solve the underlying problems. Enterprise data is siloed behind complex systems, inconsistent schemas, and fragile integrations, so pipelines need infrastructure that handles ever-changing datasets, reconciles differences across systems, and keeps data flows durable and observable. CocoIndex focuses on that engine and lets you supply the source layer yourself when needed.

See Why not build another thousand connectors?.

How does a custom source support incremental updates?

Each item runs as a processing component with a stable key, so the engine matches it to its previous run. Unchanged declared rows produce no writes, changed rows produce exactly the update needed, and items that disappear from the listing have their target rows cleaned up automatically. Functions decorated with @coco.fn participate in change detection, and memo=True caches expensive results so they are reused when inputs and logic are unchanged.

See How it stays incremental.

What happened to SourceSpec, source_connector, and provides_ordinal from v0?

They no longer exist in CocoIndex v1. SourceSpec becomes plain function arguments and app config, create() becomes your own client setup in the app lifespan, list() becomes a listing function plus the keys passed to mount_each, and get_value() becomes a fetch call inside the per-item component. Ordinals are unnecessary because the engine fingerprints inputs and logic and diffs declared rows on its own.

See From the v0 connector API.

When should I build a custom source?

When no built-in connector covers where your data lives: an internal or proprietary API, a legacy database with an unusual access path, or a SaaS product without a shipped connector. Common shapes include aggregating wikis and ticket systems into LLM context, stitching several services into one composite row, and wrapping legacy read paths to get a modern queryable table. If a built-in connector already covers your system, use it.

See When to build a custom source.