Featured image of post Building a Reusable Financial Data Pipeline with DuckDB: Consolidation Patterns

Building a Reusable Financial Data Pipeline with DuckDB: Consolidation Patterns

Deep dive into DuckDB financial data pipeline architecture: VIEW consolidation for multi-source data, Python class-based reusable engine, from bank statements to monthly P&L reports. Build a ¥299/month subscription product

DuckDB Financial Data Pipeline Architecture

Why Financial Data Pipelines Are a Product Goldmine

Data analysts have a shared pain point: every new project starts from zero code.

Client A needs financial reports — you write CSV reading, aggregation, and report generation code. Client B also needs it — you copy and tweak paths. Client C wants invoice data added — you add another table. Three months later, you have a dozen “similar but different” projects, each requiring separate maintenance.

The root cause isn’t bad code — it’s missing reusable architecture.

DuckDB’s VIEW consolidation pattern + Python class encapsulation lets you write an engine once and reuse it across N clients. This article breaks down the architecture and how to turn it into a ¥299/month × N revenue product.


1. Architecture Overview: Three-Layer Data Pipeline

A reusable financial data pipeline has three layers:

┌─────────────────────────────────────────────┐
│ Layer 1: Data Ingestion │
│ bank_transactions.csv → read_csv │
│ invoices.csv → read_csv │
│ expenses.csv → read_csv │
└──────────────────┬──────────────────────────┘
 ▼
┌─────────────────────────────────────────────┐
│ Layer 2: View Consolidation │
│ v_all_income = UNION ALL (bank + invoices)│
│ v_all_expense = UNION ALL (bank + platforms)│
└──────────────────┬──────────────────────────┘
 ▼
┌─────────────────────────────────────────────┐
│ Layer 3: Analysis Engine │
│ P&L / Cash Flow / Tax / Trend Analysis │
└─────────────────────────────────────────────┘

Key design principle: Layer 1 and Layer 2 are identical for every client. Only Layer 3 business rules need customization.

This means you only adjust analysis logic per client — no rewriting data ingestion and consolidation code.


2. Data Ingestion Layer: Multi-Source CSV in One SQL

Small businesses have data scattered everywhere: bank export CSVs, invoice system CSVs, Alipay/WeChat merchant platform CSVs. Traditional approach requires writing three different parsing scripts. With DuckDB, one SQL handles everything.

import duckdb
from pathlib import Path

class FinancialPipeline:
 """Reusable financial data pipeline engine"""
 
 def __init__(self, db_path: str):
 self.con = duckdb.connect(db_path)
 self._create_schema()
 
 def _create_schema(self):
 """Standard schema: shared across all projects"""
 self.con.execute("""
 -- Bank transactions: exported from online banking
 CREATE TABLE IF NOT EXISTS bank_transactions (
 date DATE,
 type VARCHAR,
 category VARCHAR,
 amount DECIMAL(12,2),
 counterparty VARCHAR,
 source VARCHAR
 )
 """)
 
 self.con.execute("""
 -- Invoice data: from invoice management system
 CREATE TABLE IF NOT EXISTS invoices (
 invoice_date DATE,
 invoice_number VARCHAR,
 seller VARCHAR,
 buyer VARCHAR,
 amount DECIMAL(12,2),
 tax_rate DECIMAL(5,4),
 status VARCHAR
 )
 """)
 
 self.con.execute("""
 -- Platform expenses: Alipay / WeChat / bank transfer
 CREATE TABLE IF NOT EXISTS platform_expenses (
 expense_date DATE,
 platform VARCHAR,
 category VARCHAR,
 amount DECIMAL(12,2),
 vendor VARCHAR,
 remark VARCHAR
 )
 """)

Architecture highlight: Schema defined once, reused across all projects. For new clients, just COPY ... FROM 'data/*.csv' to import data — no table structure changes needed.


3. View Consolidation Layer: UNION ALL for Unified Income/Expense口径

This is the core of the entire architecture. Different data sources have different semantics for “income” and “expense”:

  • Bank transactions with type='income' are income
  • Invoices with status='已收款' (collected) are also income
  • Alipay expenses and bank expenses are the same thing

Use VIEWs to unify them into the same口径:

-- Unified income view: bank income + collected invoices
CREATE OR REPLACE VIEW v_all_income AS
SELECT 
 date AS trans_date,
 'bank' AS source_type,
 category,
 amount,
 counterparty AS partner
FROM bank_transactions 
WHERE type = 'income'

UNION ALL

SELECT 
 invoice_date AS trans_date,
 'invoice_system' AS source_type,
 'sales' AS category,
 amount,
 buyer AS partner
FROM invoices 
WHERE status = 'collected';

-- Unified expense view: bank expenses + platform expenses
CREATE OR REPLACE VIEW v_all_expense AS
SELECT 
 date AS trans_date,
 'bank' AS source_type,
 category,
 amount,
 counterparty AS partner
FROM bank_transactions 
WHERE type = 'expense'

UNION ALL

SELECT 
 expense_date AS trans_date,
 'platform' AS source_type,
 category,
 amount,
 vendor AS partner
FROM platform_expenses;

Why VIEWs instead of physical tables?

  1. Real-time freshness: VIEWs read from base tables on every query — no re-ingest needed when new data arrives
  2. Storage efficient: No duplication, income and expense stored only once
  3. Centralized logic: Consolidation rules written once, all analysis queries benefit automatically
  4. Traceable: Each field has source_type, enabling data lineage tracking

4. Analysis Engine Layer: One-Click P&L Generation

With unified income/expense views, analysis logic becomes straightforward:

-- Monthly P&L statement
CREATE OR REPLACE VIEW v_monthly_pl AS
WITH monthly_income AS (
 SELECT 
 DATE_TRUNC('month', trans_date) AS month,
 SUM(amount) AS total_income,
 COUNT(*) AS transaction_count
 FROM v_all_income
 GROUP BY 1
),
monthly_expense AS (
 SELECT 
 DATE_TRUNC('month', trans_date) AS month,
 SUM(amount) AS total_expense,
 COUNT(*) AS transaction_count
 FROM v_all_expense
 GROUP BY 1
),
monthly_tax AS (
 -- Extract estimated taxes from invoices
 SELECT 
 DATE_TRUNC('month', invoice_date) AS month,
 SUM(amount * tax_rate) AS estimated_tax
 FROM invoices
 WHERE status IN ('issued', 'collected')
 GROUP BY 1
)
SELECT 
 i.month,
 ROUND(i.total_income, 2) AS revenue,
 ROUND(e.total_expense, 2) AS expenses,
 ROUND(i.total_income - e.total_expense, 2) AS gross_profit,
 ROUND((i.total_income - e.total_expense) / NULLIF(i.total_income, 0) * 100, 2) AS profit_margin_pct,
 ROUND(COALESCE(t.estimated_tax, 0), 2) AS estimated_tax,
 ROUND(i.total_income - e.total_expense - COALESCE(t.estimated_tax, 0), 2) AS net_profit,
 i.transaction_count + e.transaction_count AS total_transactions
FROM monthly_income i
LEFT JOIN monthly_expense e ON i.month = e.month
LEFT JOIN monthly_tax t ON i.month = t.month;

Add MoM and YoY growth (one SQL, no pandas merge needed):

-- Full P&L with MoM and YoY
SELECT 
 month,
 revenue,
 expenses,
 gross_profit,
 profit_margin_pct,
 -- Previous month (MoM)
 LAG(revenue) OVER w AS prev_month_revenue,
 ROUND(
 (revenue - LAG(revenue) OVER w) 
 / NULLIF(LAG(revenue) OVER w, 0) * 100, 2
 ) AS mom_growth_pct,
 -- Same month last year (YoY)
 LAG(revenue, 12) OVER w AS same_month_last_year,
 ROUND(
 (revenue - LAG(revenue, 12) OVER w) 
 / NULLIF(LAG(revenue, 12) OVER w, 0) * 100, 2
 ) AS yoy_growth_pct
FROM v_monthly_pl
WINDOW w AS (ORDER BY month);

5. Python Encapsulation: One Engine, N Clients

Complete reusable engine class:

class FinancialPipeline:
 """Financial data pipeline engine — write once, use N times"""
 
 def __init__(self, db_path: str):
 self.con = duckdb.connect(db_path)
 self._setup_views()
 
 def _setup_views(self):
 """View consolidation layer: shared across all projects"""
 # v_all_income and v_all_expense from above
 pass
 
 def ingest(self, month: str, data_dir: Path):
 """Data ingestion: called once per month"""
 for csv_file in data_dir.glob(f"{month}*.csv"):
 table_name = csv_file.stem
 self.con.execute(f"""
 COPY {table_name} FROM '{csv_file}' 
 (FORMAT CSV, HEADER, AUTO_DETERMINE)
 """)
 print(f"✅ {month} data ingested")
 
 def generate_report(self, month: str) -> pd.DataFrame:
 """Generate monthly P&L statement"""
 return self.con.execute(f"""
 SELECT * FROM v_monthly_pl 
 WHERE month = '{month}-01'::DATE
 """).fetchdf()
 
 def export_parquet(self, month: str, output_dir: Path):
 """Export as Parquet — 5x faster subsequent queries"""
 df = self.generate_report(month)
 df.to_parquet(output_dir / f"pl_{month}.parquet")
 
 def close(self):
 self.con.close()

Usage:

# Client A: Restaurant
pipeline_a = FinancialPipeline("clients/restaurant_01.duckdb")
pipeline_a.ingest("2026-07", Path("clients/restaurant_01/data"))
report_a = pipeline_a.generate_report("2026-07")
pipeline_a.export_parquet("2026-07", Path("clients/restaurant_01/output"))
pipeline_a.close()

# Client B: E-commerce — same engine, different data
pipeline_b = FinancialPipeline("clients/ecommerce_02.duckdb")
pipeline_b.ingest("2026-07", Path("clients/ecommerce_02/data"))
report_b = pipeline_b.generate_report("2026-07")
pipeline_b.close()

Key advantage: Client A and Client B use the same code. Each client has an independent DuckDB database file. Adding a new client = creating a new folder + importing data, zero code changes.


6. DuckDB vs Traditional Approach Comparison

DimensionDuckDB Pipelinepandas + ManualTraditional ETL
Multi-source CSV loadingOne SQL COPYMultiple pd.read_csv + mergeComplex configuration
VIEW consolidationNative SQL VIEWMultiple DataFrame mergesDepends on scheduler
MoM/YoY growthLAG() window functionNeed self-mergeCustom scripting
Engine reusabilityClass-based, zero-code reuseCopy-pasteRe-configuration needed
DeploymentZero (single .duckdb file)Requires Python envRequires server

8. Production-Grade: Data Quality Checks

A real production pipeline needs data quality validation:

-- Data quality check view
CREATE OR REPLACE VIEW v_data_quality AS
SELECT 
 'bank_transactions' AS table_name,
 COUNT(*) AS total_rows,
 SUM(CASE WHEN amount IS NULL THEN 1 ELSE 0 END) AS null_amounts,
 SUM(CASE WHEN amount < 0 THEN 1 ELSE 0 END) AS negative_amounts,
 SUM(amount) AS total_amount
FROM bank_transactions

UNION ALL

SELECT 
 'invoices' AS table_name,
 COUNT(*) AS total_rows,
 SUM(CASE WHEN amount IS NULL THEN 1 ELSE 0 END),
 SUM(CASE WHEN amount < 0 THEN 1 ELSE 0 END),
 SUM(amount)
FROM invoices;

With quality checks, you can automatically validate data integrity before delivery. This is your charging justification — “I don’t just give you reports, I guarantee data quality”.


Summary

The core value of this architecture isn’t the technology itself — it’s reusability:

  1. VIEW consolidation pattern: Unifies multi-source data semantics, write rules once, all analyses benefit automatically
  2. Class-based engine: One FinancialPipeline class serves N clients, zero code for new clients
  3. DuckDB single file: Each client gets one .duckdb file — distribution, backup, and migration are trivial
  4. **Subscription **: ¥299/month, marginal cost near zero, true passive income

After this article, you have all the skills needed to build financial data products. The next step is finding a real client and running the full pipeline with real data.

💡 More DuckDB pipeline patterns → duckdblab.org


本文信息

项目内容
DuckDB 版本v1.5.x(部分功能基于 v2.0 Preview)
最后验证2026-09-12
测试环境Linux / x86_64 / 16GB RAM
官方文档DuckDB Documentation
GitHubpengzz9527/duckdb-blog

如发现错误,欢迎通过 GitHub Issue 或邮件 [email protected] 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials