Analytics engineering

dbt model development, from staging to production-ready marts

We build and maintain dbt projects: staging, intermediate, and mart layers, with tests, documentation, and CI/CD built in from day one.

  • dbt
  • Redshift
  • Snowflake (schema layer)
  • AWS
$ dbt build --select +mart_revenueRunning with dbt=1.9.0Found 3 models, 9 tests, 2 sources1 of 5 OK created view model stg_orders ......... [OK 0.42s]2 of 5 PASS not_null_stg_orders_order_id ......... [PASS 0.11s]3 of 5 OK created table model int_order_items .. [OK 1.08s]4 of 5 OK created table model mart_revenue ..... [OK 0.94s]5 of 5 PASS unique_mart_revenue_revenue_key ...... [PASS 0.13s]Completed successfullyDone. PASS=5 WARN=0 ERROR=0 SKIP=0 TOTAL=5

What is dbt model development?

dbt model development is the practice of writing modular SQL transformations, organised into staging, intermediate, and mart layers, with automated tests and generated documentation, so an analytics team can trust and trace every metric back to its source.

The problem

Symptoms of a project that outgrew its structure

Most dbt projects start clean. They don't stay that way.

A project starts with eight models and clear naming conventions. It grows to two hundred, with a couple of people left who remember why int_orders_v2_final exists, CI that passes while the numbers are wrong, and a warehouse bill that climbs every quarter.

This is a common failure mode in growing dbt projects, not a rare one, and it's fixable with the same layering discipline that would have prevented it in the first place.

  • The project has two hundred models and nobody is sure which ones anything still reads from.
  • One model breaks, five dashboards go quietly wrong, and nobody gets paged.
  • Onboarding an analytics engineer takes a month, because the conventions only exist in someone's head.

Our approach

Every model lives in one of three layers

Staging models map one-to-one with source tables: renamed, cast, and nothing else. Intermediate models hold the joins and the business logic, built to be reused and never queried directly by a BI tool. Marts are the final, documented tables your dashboards and stakeholders actually read.

Every model gets a primary-key test and a not-null test at minimum. Every project gets dbt docs generation and a CI pipeline that runs dbt build on each pull request. This isn't a stylistic preference. It's what stops the failure modes above from coming back.

A dbt project rarely lives alone. If yours needs to sit inside a broader ingestion and warehousing setup, see Data Platform Architecture.

Our methodology

How we structure dbt models

01

Staging layer

stg_*

One model per source table. Raw data cleaned, renamed, and cast to the right types. No joins, no business logic. This layer is what makes everything downstream stable and refactorable.

  • One source, one model
  • Type casting and renaming only
  • Tested for not-null and uniqueness

02

Intermediate layer

int_*

Joins, deduplication, and business logic live here, with ref() dependencies declared explicitly. This is where data quality issues get caught before they reach analysts.

  • Business logic encapsulated
  • Explicit ref() dependency graph
  • Uniqueness and referential integrity tests

03

Mart layer

fct_*dim_*

Analytics-ready fact and dimension tables, designed for direct BI tool consumption, with metric definitions aligned to how the business actually measures performance.

  • Kimball-style facts and dimensions
  • Metric-aligned naming
  • Documentation generated from the project itself

What we deliver

Six capabilities, one coherent standard

dbt project build

Greenfield setup: naming conventions, layering strategy, and a dbt_project.yml that still makes sense in year two.

dbt rescue

Existing projects: untangling circular refs, adding coverage to untested load-bearing models, and fixing slow builds caused by missing incremental strategies.

Testing & documentation

Generic and singular tests, full dbt docs generation, and source freshness checks on the tables everything else depends on.

CI/CD for dbt

dbt build on every pull request, state-deferred runs so only affected models rebuild, and a promotion path into production.

Performance & cost

Incremental materialisation, partitioning and clustering strategy, and removing the work a build repeats on every single run.

Warehouse & AWS plumbing

Schema migration, versioned DDL, and the Lambda, ECS, S3, and Terraform layer a dbt project needs in order to actually run in production.

Where we work

The warehouse layer underneath the models

Redshift

Production dbt.

  • dbt in production: staging through mart layers, medallion architecture
  • Schema migration and versioned DDL
  • AWS-side plumbing: Lambda, ECS, S3, Terraform

Snowflake

Schema and platform layer, stated with its real boundary.

  • Schema migration and versioned DDL in production
  • Hybrid tables (Unistore) in production
  • COPY and external stages for ingestion

What we don't claim: senior-level warehouse operations on Snowflake. Credit management, clustering strategy, RBAC hierarchy design and Snowpipe are real, current work for someone else. We would rather say so up front than have a project find out midway through.

Credibility, not claims

What our dbt models look like

models/staging/stg_orders.sql

-- One model per source table: rename, cast, nothing else.
with source as (
    select * from {{ source('ecommerce', 'orders') }}
),
renamed as (
    select
        order_id,
        customer_id,
        cast(created_at as timestamp) as order_created_at,
        status                        as order_status
    from source
)
select * from renamed

Before & after

One model doing four jobs, split into three that each do one

models/orders.sql, before
select
  o.id as order_id,
  o.customer_id,
  c.email,
  o.total / 100.0 as total_usd,
  case when o.status = 'A' then 'active'
       when o.status = 'C' then 'cancelled'
       else 'unknown' end as status,
  o.created_at
from raw.orders o
left join raw.customers c
  on o.customer_id = c.id
where o.deleted_at is null
staging + marts + tests, after
-- staging/stg_orders.sql
select
  id as order_id,
  customer_id,
  total / 100.0 as total_usd,
  status_code as status
from {{ source('raw', 'orders') }}
where deleted_at is null

-- marts/fct_orders.sql
select
  o.order_id,
  o.customer_id,
  c.email,
  o.total_usd,
  o.status
from {{ ref('stg_orders') }} o
join {{ ref('stg_customers') }} c
  using (customer_id)

How we work

A structured, de-risked process

  1. Audit the existing project, or scope a greenfield one

    We map every model, source, and test that exists today, or the sources you need to model from scratch.

    3 to 5 days
  2. Design the layering and naming conventions

    Staging, intermediate, and mart boundaries, plus naming standards your team will actually follow.

    About a week
  3. Build and test the models

    Models and tests are built and reviewed layer by layer, not all at once at the end.

    2 to 6 weeks, by scope
  4. Wire up CI/CD

    dbt build in your existing CI, with state-deferred runs once the project is large enough to benefit.

    3 to 5 days
  5. Handover and documentation walkthrough

    A working session with your analytics engineers, so the project doesn't depend on us to maintain.

    2 to 3 days

Why layering matters

What changes when a project has real structure

  Ad-hoc SQL in the warehousedbt without conventionsLayered dbt project
Lineage Undocumented, lives in people's headsPartially traceable through ref()Full DAG, generated from the project
Test coverage Manual spot checks, after the factA few tests, on whichever models someone rememberedNot-null and uniqueness on every key, enforced in CI
Changing a definition Find and edit every query that uses itEdit several models and hope nothing downstream breaksChange one staging model; everything downstream follows
Onboarding an engineer Ask whoever wrote itRead the models and infer the rulesRead the conventions, then the docs the project generates

Ways to work together

How we engage

Day-rate / project engagement

For larger or ongoing work

  • Greenfield builds, migrations, or platform work
  • Scope agreed before work starts
  • 30% deposit before work starts on every contract, balance invoiced against agreed milestones

Rate quoted against scope after a first call

Discuss scope

Common questions

Frequently asked questions

A dbt model is a SQL (or Python) file that defines a transformation as a select statement. dbt compiles it, manages the dependency graph between models via ref(), and materializes it in your warehouse as a table or view.

dbt Core is the open-source CLI you run yourself, in your own CI/CD. dbt Cloud is a hosted service that adds scheduling, an IDE, and a managed environment on top of the same underlying engine. We work with both, depending on what a project already has in place.

A greenfield build is typically four to eight weeks depending on the number of source systems and the complexity of the business logic. A rescue of an existing project, auditing and restructuring what's already there, is usually two to four weeks. The fixed-price audit sprint above is five days and answers the question of where to start.

Production dbt work on Redshift, and Snowflake at the schema and platform layer: schema migration, versioned DDL, and hybrid tables. See the warehouse section above for exactly what that does and doesn't cover.

Knowledge transfer is built into every engagement: documentation generated from the project itself, plus a walkthrough session so your team can maintain what's delivered without us in the room. A standalone training engagement can be scoped on request.

Let's build something together

Tell us what's broken or what you're building, and we'll take it from there.