---
title: "How We Built Branches: Sharing Production Data Without Copying It"
excerpt: "The engineering behind Tinybird branches: zero-copy partition sharing via ClickHouse metadata, Kafka consumer group isolation, S3 sample imports, and CI/CD preview deployments."
authors: "Alberto Romeu"
categories: "Engineering Excellence"
createdOn: "2026-02-26 00:00:00"
publishedOn: "2026-02-26 00:00:00"
status: "published"
---

When we shipped [branches](https://www.tinybird.co/product/branches) [in beta](https://www.tinybird.co/blog/introducing-branches-in-beta), connectors didn't work. If your data came from Kafka or S3, you couldn't test with real data. Preview deployments weren't there either.

We [fixed all of that](https://www.tinybird.co/blog/branches-ga). This post is about the engineering behind it: how we share production data without copying it, isolate connectors per branch, and deploy changes to production with zero downtime.

## Branch architecture

A branch is a full Tinybird project in an ephemeral cloud environment. Its own ClickHouse{% sup %}®{% /sup %} database, its own token namespace, its own API surface. You can truncate tables, redefine schemas, drop pipes. None of it touches production.

Branch creation is an API call (`POST /v1/environments?name={name}`), decoupled from git. Creation is asynchronous: Tinybird provisions compute, mirrors tokens, and optionally attaches partition data.

Each branch gets independent tokens with the same names and scopes as production, so your application code works unchanged. The SDK auto-detects the environment and resolves the right token ([details in the GA post](https://www.tinybird.co/blog/branches-ga)).

## Zero-copy partition sharing

When you create a branch with `--last-partition`, Tinybird identifies the last modified partition per datasource and makes it available in the branch. A partition containing gigabytes of data "copies" without duplicating a single byte. No additional storage cost.

### Hardlinks, not copies

Tinybird runs a [fork of ClickHouse](https://www.tinybird.co/blog/why-we-maintain-a-clickhouse-fork-at-tinybird) that preserves and extends zero-copy replication (which upstream ClickHouse{% sup %}®{% /sup %} is [removing](https://github.com/ClickHouse/ClickHouse/issues/58392#issuecomment-1874265940)) with storage/compute separation.

A production workspace is a ClickHouse database on a cluster configured with cloud storage (S3/GCS). A branch is a different database on the same cluster, with its own tables and its own ZooKeeper paths.

When you create a branch with `--last-partition`, Tinybird executes `ALTER TABLE ... ATTACH PARTITION FROM` to bring partitions from the production tables into the branch tables. The key is what happens at the storage layer.

ClickHouse's `cloneAndLoadDataPart()` checks whether source and destination tables are on the same disk. Since both databases share the same cluster and storage policy (same S3 bucket), they are. So ClickHouse takes the **hardlink path**: it creates a new metadata entry in the branch table that references the same S3 object keys as the production table and increments a reference count. No `CopyObject` calls to S3. No bytes transferred.

![Diagram showing Production and Branch databases sharing the same S3 object via metadata references](branches01.png)

### Why branches can't corrupt production

This sounds dangerous. Two databases pointing at the same S3 objects. But it's safe because ClickHouse{% sup %}®{% /sup %} parts are **immutable**. Once a part is written to S3, it is never modified in place.

- **INSERT** creates new parts (new S3 objects). The shared parts are untouched.
- **ALTER TABLE UPDATE / DELETE** (mutations) read the existing parts, write new parts with the changes applied, and mark the old parts for deletion. The old S3 objects stay until garbage collection runs.
- **Merges** combine small parts into larger ones, producing new S3 objects. The originals are marked for deletion.
- **TRUNCATE** removes all metadata references from that table. The S3 objects are only deleted when the reference count across all tables drops to zero.

Every write operation produces *new* objects. The shared objects are read-only. The branch can truncate, mutate, or rewrite its tables without affecting production, and production can keep ingesting without affecting the branch.

![Diagram showing Production and Branch databases writing new immutable parts to S3 without modifying shared objects](branches02.png)

When the branch is deleted, its metadata references are removed, reference counts decrement, and S3 objects are garbage-collected only if no other table references them.

### Why last partition, not the full table

`--last-partition` attaches one partition per datasource. This is a deliberate choice, not a limitation. Attaching the full table would mean replicating potentially hundreds of partition references at the metadata level for data you'll never query in an ephemeral branch.

One partition is enough: schema changes validate against real production values, and query performance on a single partition gives a reasonable proxy for the full table.

The more important data source in a branch is live connector data. Start a Kafka consumer with its own group, or point an S3 connector at a staging bucket. You get real-time ingestion in an isolated environment without replicating production load into an ephemeral branch.

### What you give up

The zero-copy path only works when source and destination share the same disk configuration (same S3 bucket/storage policy). If branches ran on a separate cluster or region, ClickHouse would take the `freezeRemote()` path and copy every S3 object. Currently all branches run on the same cluster as production. Dedicated branch infrastructure is on the roadmap, which would also remove this constraint.

Production can't garbage-collect S3 objects that a branch still references. Long-lived branches can delay cleanup of old parts. Delete branches when you're done.

The attach operation creates hardlinks and registers parts in ZooKeeper individually. A large partition can contain hundreds of parts, each requiring multiple S3 metadata operations and ZK writes. At 50GB+, the cumulative latency can exceed timeouts, so the attach is skipped for that datasource.

The [Classic branches implementation](https://www.tinybird.co/docs/classic/work-with-data/organize-your-work/branches) works differently.

## Connector isolation

Copying data at rest is the easier part. The hard engineering problem is connectors: Kafka consumers, S3 sync jobs, GCS imports. Each has state (consumer group offsets, sync cursors, SQS queues) that can't be naively shared between production and a branch.

### Kafka: new group, latest offset

If a branch joins the same consumer group as production, it steals messages. If it starts from offset 0, it replays the entire topic history. Neither is acceptable.

The solution: branches create Kafka connectors **stopped by default**, and when started, they use a **new consumer group** with a unique ID that starts from the **latest offset**.

When started, the `start` endpoint generates a new `kafka_group_id` by appending a UUID suffix: `{original_group_id}__{8-char-uuid}`. Since this group has never committed offsets, Kafka's `auto.offset.reset` starts from the latest offset. The branch only sees new messages arriving after it starts.

![Diagram of Kafka topic with two consumer groups: production group reads all messages, branch group reads from latest offset only](branches03.png)

### S3 and GCS: sample, don't sync

The problem is different from Kafka. A production S3 connector maintains a sync cursor and watches for new files. It may have synced thousands of files over months. A branch needs representative data, not a full backfill.

The solution: `POST /v0/datasources/{name}/sample` imports the latest files from the bucket (1-10) without touching the production sync cursor.

Three constraints make this safe: `match_limit` caps files processed (1-10, default 1), an epoch `from_time` bypasses the production `last_sync_date`, and `skip_last_sync_update` ensures the sample doesn't update the origin datasource's sync cursor.

### The `--with-connections` flag

`--with-connections` enables connectors in the branch by creating DataLinkers for your connector datasources. Without the flag, connector datasources exist in the branch (they're part of the schema), but they have no active connectors.

`tb dev` defaults to `--with-connections` on branches. Branches inherit production secrets by default; override with `tb secret set` to point at a staging cluster or test bucket instead.

## From branch to production

Branches are where you iterate. The deployment API is how changes reach production.

### Schema migrations as an API call

Manual [ClickHouse schema migrations](https://www.tinybird.co/blog/when-not-to-migrate-your-data) mean new tables, materialized views, backfills, UNION views, traffic swaps, and cleanup, multiplied by every table in the dependency chain. This is the [problem `tb deploy` solves](https://www.tinybird.co/blog/tb-deploy):

```tinybird-cli
tb deploy
```

The deployment goes through four stages:

1. **Initialization.** Tinybird analyzes your schema changes and builds a dependency graph: which tables changed, which materialized views depend on them, what backfills are needed.

2. **Data migration.** For schema changes that require it, Tinybird creates auxiliary tables, runs a backfill, and maintains UNION views so production reads keep working during the transition.

3. **Promotion.** A metadata swap points production to the new tables.

4. **Cleanup.** Old tables are removed, auxiliary tables are merged back.

For backward-incompatible changes, you declare a `FORWARD_QUERY` to transform existing data ([details in the tb deploy post](https://www.tinybird.co/blog/tb-deploy)). The algorithm only migrates from the most upstream change downstream, so a downstream materialized view change leaves your 14TB landing table untouched.

`tb deploy --check` dry-runs it.

### CLI-first is agent-first

Everything described in this post (branch creation, partition attach, connector isolation, schema migrations) is an API call. That's not an accident. Tinybird is CLI and API-first: every operation you can do in the UI, you can do from a terminal or an HTTP request.

This was designed for humans. But it turns out the same properties that make a [CLI](https://www.tinybird.co/docs/forward/dev-reference) and [API](https://www.tinybird.co/docs/api-reference) ergonomic for developers make them accessible to coding agents. An agent doesn't need to figure out how to write a ClickHouse migration script, manage consumer group offsets, or coordinate a blue-green deployment. It runs `tb branch create`, `tb dev`, and `tb deploy`. The complexity of branching, authentication, connector isolation, and schema migrations is wrapped into commands.

[Tinybird skills](https://github.com/tinybirdco/tinybird-agent-skills) close the last gap. They give agents the context they need: what commands exist, what flags to use, how to structure a datasource file, how to write a pipe. The agent reads the skill, calls the CLI, validates against real data in a branch, and deploys. The full workflow in the [GA announcement](https://www.tinybird.co/blog/branches-ga) shows what this looks like in practice, including [preview deployments](https://www.tinybird.co/docs/forward/test-and-deploy/deployments/cicd) and automatic token resolution per CI environment.

## Build with it

Branches are available now. Break something in a branch instead of production.

- [Branches docs](https://www.tinybird.co/docs/forward/test-and-deploy/branches)
- [CI/CD docs](https://www.tinybird.co/docs/forward/test-and-deploy/deployments/cicd)
- [web-analytics-starter-kit](https://github.com/tinybirdco/web-analytics-starter-kit): full working example with branches, preview deployments, and CI/CD
- [TypeScript SDK](https://www.npmjs.com/package/@tinybirdco/sdk)

Tell us what works and what doesn't: [Slack community](https://www.tinybird.co/community) (`#feedback`) or [GitHub](https://github.com/tinybirdco/tinybird-sdk-typescript).
