Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eef9f3e
docs(blog): rewrite build-system introduction from first principles
iamgp Sep 17, 2026
aac9381
docs(blog): explain the compiler from first principles
iamgp Sep 17, 2026
1ff3012
docs(blog): deepen workspace graph, typing, lineage and contracts
iamgp Sep 17, 2026
3397124
docs(blog): explain state, planning and verified cache reuse
iamgp Sep 17, 2026
b37bf7a
docs(blog): deepen incremental model explanation
iamgp Sep 17, 2026
7f00def
docs(blog): rewrite WAP, evidence and promotion from first principles
iamgp Sep 17, 2026
9e71614
docs(blog): rewrite dbt migration chapter around semantic translation
iamgp Sep 17, 2026
9b7fec9
docs(blog): add execution engine chapter
iamgp Sep 17, 2026
692bce8
docs(blog): add machine interface and daemon chapter
iamgp Sep 17, 2026
e45091e
docs(blog): add end-to-end v0.1 architecture chapter
iamgp Sep 17, 2026
eca4c7c
docs(blog): expand series index and reading guide
iamgp Sep 17, 2026
6660fbe
docs: reserve Phlo name for lakehouse framework
iamgp Sep 17, 2026
3439278
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
51a4c8e
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
bbad76d
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
295d40d
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
94c76a9
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
f2a32a1
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
be8269a
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
7ecf8d9
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
5a3c6bf
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
0042e35
docs: use Phlo Transform name consistently
iamgp Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 207 additions & 47 deletions docs/blog/01-why-sql-needs-a-build-system.md
Original file line number Diff line number Diff line change
@@ -1,71 +1,231 @@
# 1. Why does SQL need a build system?

Start with one query. You have a table of raw events and you want a daily
count:
Start with the smallest possible data transformation: one SQL query.

```sql
select
occurred_at::date as day,
kind,
count(*) as n
count(*) as events
from landing.events
group by 1, 2
```

Run it once in a console, paste the result into a dashboard, done. No build
system needed. So where does the complexity come from?
If you run that once in a console, look at the result, and throw it away, you do not need Phlo Transform. You do not need dbt either. You barely need a file.

## The moment it stops being one query
The interesting problems begin when the result stops being temporary.

The query becomes a system when three things happen:
## A query becomes a model when other things rely on it

1. **The result is stored.** Now it is a table, `marts.daily`, that other
queries read, and someone has to refresh it on a schedule.
2. **Other queries depend on it.** A weekly rollup reads `marts.daily`. A
churn model reads the rollup. You have a dependency graph whether you
wrote it down or not.
3. **The query changes.** Someone edits the definition. Which downstream
tables are stale now? Does a cosmetic edit justify a full rebuild?
Suppose we save the result as `marts.daily_events` because a dashboard needs it every morning.

None of these problems are about SQL. A compiler and a build system solve
the same problems for code: dependencies, incremental rebuilds, and knowing
what a change invalidates. A transformation layer is a build system whose
source language happens to be SQL.
Now we have two things:

## The accidental complexity tax
- a **definition**: the SQL above;
- a **materialisation**: the table or view produced from that definition.

Existing tools answer these questions, but with machinery on top:
That distinction is fundamental. The SQL is what we *want*. The relation in the warehouse is what currently *exists*.

- You declare dependencies with a special function, `ref('model')`, instead
of letting the tool read the `from` clause you already wrote.
- Models carry Jinja templates, so the file you edit is not the SQL that
runs. The file is a program that produces SQL, and you cannot know what it
does without executing it.
- Incremental behaviour lives in user-editable materialisation macros:
hundreds of lines of templated DDL per adapter.
- Correctness gates mostly do not exist. `dbt build` mutates production
directly, and you find out what it did by watching it.
A transformation tool has to keep those two things aligned.

Each item is a workaround for missing structure. A tool that understands
SQL can infer the dependency. A tool that owns materialisation does not
need a macro for incremental. A tool that records what it built can tell
you what a change invalidates before it touches anything.
Then another query reads `marts.daily_events`:

## What first principles look like
```sql
select
date_trunc('week', day) as week,
sum(events) as events
from marts.daily_events
group by 1
```

We now have a dependency:

```text
landing.events
│
▼
marts.daily_events
│
▼
marts.weekly_events
```

Nothing created that graph for us. It already existed the moment one query read another result.

This is the first useful idea in Phlo Transform:

> **The dependency graph is a property of the SQL, not of the tool.**

The tool's job is to discover it accurately.

## What is a relation?

A SQL engine works with named things such as tables and views. In this series, we will use **relation** as the general term for one of those queryable objects.

For example:

```text
landing.events
marts.daily_events
analytics.customer_value
```

A relation may be an external input that Phlo Transform does not build, or it may be the physical output of a model that Phlo Transform does build.

That gives us two more useful terms:

- a **source** is an input relation that exists outside the transformation graph;
- a **model** is SQL owned by the workspace that produces a relation.

If model B reads model A, A is upstream of B and B is downstream of A.

## Why scheduling alone is not enough

A simple scheduler could run model A and then model B every hour. That works until the system changes.

Imagine someone reformats `daily_events.sql` without changing what it computes. Should the table rebuild?

Now imagine they change:

```sql
occurred_at::date
```

to:

```sql
date_trunc('day', occurred_at at time zone 'UTC')
```

That may be a real semantic change. `daily_events` is now stale, and `weekly_events` may be stale too.

Or perhaps the SQL is unchanged but the upstream source moved to a new Iceberg snapshot. The model definition did not change, but its input did.

Or perhaps the table was modified outside the transformation tool after the last successful run. The tool's state says one thing while the warehouse holds another.

A scheduler cannot answer these questions. It knows *when* to run commands, not *why* an output is valid.

## This is a build-system problem

Software build systems already solve the same class of problem.

A compiler does not rebuild an entire program merely because a file's whitespace changed. It understands source files, dependencies, outputs and invalidation.

A SQL transformation system needs the same concepts:

1. **Source language** — the SQL files people write.
2. **Compiler** — parses those files and understands what they mean.
3. **Dependency graph** — which model reads which model or source.
4. **Build state** — what was previously materialised and from which inputs.
5. **Plan** — what needs to happen now, and why.
6. **Executor** — performs the required physical work.
7. **Evidence** — proves what was actually built and tested.

The warehouse is not the build system. It is the execution target.

## What does “materialise” mean?

To **materialise** a model means to turn its SQL definition into a durable warehouse object.

The simplest choices are:

- **view** — store the query definition; compute rows when queried;
- **table** — run the query and store its result;
- **incremental table** — update only the part that needs changing.

For a table model, a build might conceptually execute:

```sql
create or replace table marts.daily_events as
select ...
```

For an incremental model it might perform an `INSERT`, `MERGE`, partition replacement or time-window update instead.

The important point is that the model describes *what the data means*. The engine should own the mechanics of producing it.

## Desired state and current state

Phlo Transform's planning model becomes much easier to understand if you separate two worlds.

### Desired state

The compiler calculates what each model should look like now:

- its canonical SQL semantics;
- its configuration;
- its contract;
- the exact versions of upstream models;
- observed source states;
- the compiler semantics version.

These inputs produce a content-addressed model version.

### Current state

The state store records what Phlo Transform previously materialised:

- model version;
- environment;
- physical target;
- adapter;
- producing run;
- materialisation timestamp;
- and, where the adapter can prove it, a strong physical output identity such as an Iceberg snapshot id.

A plan is the comparison between desired and current state.

That comparison produces a small vocabulary:

```text
BUILD the required output is not currently proven to exist
SKIP this environment already has the required verified output
CACHED the required verified output already exists and can be adopted here
```

We will unpack all three later. For now, notice the important property: these are decisions backed by evidence, not merely commands.

## The basic Phlo Transform loop

A local workspace can start with no external infrastructure:

```bash
phlo-transform init
phlo-transform check
phlo-transform plan --adapter duckdb
phlo-transform run --adapter duckdb
```

`check` answers:

> Can the workspace be compiled into a coherent model?

`plan` answers:

> Given the compiled workspace and the current state, what would happen?

`run` performs the plan and then runs the relevant tests.

The same compiler and engine can target Trino, Iceberg and Nessie for the lakehouse path. The execution substrate changes; the model of the workspace does not.

## Why not “just run the SQL”?

Because once transformations are shared, stored and changed over time, the hard questions are no longer SQL syntax questions:

- What depends on this model?
- Which outputs became stale?
- Did the model change, or only its formatting?
- Did an input change?
- Does the physical table still match what we recorded?
- Can an existing output be reused safely?
- Which tests passed against the exact candidate we want to publish?
- Did production move after we audited it?
- What evidence authorised a promotion six months later?

Strip it back. A transformation workspace needs:
Those are build-system questions.

- **SQL files.** The source of truth, executable as written.
- **A compiler** that parses the files into a typed dependency graph.
- **State** that records what was built, so rebuilds stay minimal and
explainable.
- **A plan step before apply.** Every mutation follows an inspectable
decision.
- **Tests and audits inside the run**, not as a separate workflow.
- **Promotion gated on evidence**, not on hope.
Phlo Transform starts from that premise and tries to remove everything that does not follow from it.

That is Phlo Transform. The rest of this series walks through each piece
and shows the workflow running end to end, including a real dbt project
translated and executed on DuckDB.
The next step is the source language itself. If SQL already contains relation names and expressions, how much additional syntax does a compiler actually need?

*Next: [SQL should stay SQL](02-sql-should-stay-sql.md).*
*Next: [SQL should stay SQL](02-sql-should-stay-sql.md).*
Loading
Loading