Featured image of post Building a Personal Data Journalism Product with DuckDB — Turn Data into Subscription Content

Building a Personal Data Journalism Product with DuckDB — Turn Data into Subscription Content

In-depth tutorial on building a personal data journalism product with DuckDB: multi-source data ingestion, SQL correlation analysis, anomaly detection, and automated report generation. Learn to turn public data into subscription-worthy content assets.

Introduction: Can One Person Do Data Journalism?

Have you noticed a growing trend — more and more independent creators are making money through “data journalism”?

Data columns in Caixin, infographics in The Economist, even individual bloggers on Bilibili and Xiaohongshu who tell stories with data — their core competency isn’t writing articles. It’s discovering stories in public data that others miss.

And here’s the truth: you can do all of this with just DuckDB.

No data team needed. No BI tools required. No servers to manage. Just DuckDB + a few public data sources + some SQL, and you’ve got an end-to-end pipeline from data collection to report generation.


Part 1: What Is a “Data Journalism Product”?

Traditional journalism requires reporters on the ground, interviews, and article writing. Data journalism follows a completely different logic:

  1. Find public data (government open data, APIs, web scraping)
  2. Analyze correlations with SQL (DuckDB’s sweet spot)
  3. Discover anomalous patterns (this is your “story”)
  4. Automate report generation (Markdown / PDF / Newsletter)
  5. **Publish and ** (paid subscriptions, ads, consulting leads)

The real barrier here isn’t technical — it’s your sensitivity to data. And DuckDB drops that technical barrier to zero. All analysis is SQL, and the entire workflow is automatable.


Part 2: Hands-On — Building a “Weekly City Real Estate Market Report”

Let’s walk through building a weekly real estate market report using DuckDB to analyze three data sources (property transactions, social media buzz, population migration), detect investment opportunity signals, and generate a publishable weekly report.

Step 1: Ingest Multi-Source Data

DuckDB’s killer feature: it can natively read CSV, JSON, Parquet files, and even pull API data directly via the HTTP extension.

import duckdb
from pathlib import Path

con = duckdb.connect(":memory:")

# Load built-in extensions
con.execute("INSTALL httpfs; LOAD httpfs")
con.execute("INSTALL json; LOAD json")

# Simulate three data sources (replace with real data in production)

# Data Source A: Property transaction records
con.execute("""
CREATE TABLE real_estate_transactions AS
SELECT * FROM (VALUES
 ('Beijing', 'Chaoyang', 'Residential', 2025, 1, 12500000, 120),
 ('Beijing', 'Haidian', 'Residential', 2025, 1, 15800000, 95),
 ('Beijing', 'Chaoyang', 'Commercial', 2025, 1, 28000000, 200),
 ('Shanghai', 'Pudong', 'Residential', 2025, 1, 11200000, 110),
 ('Shanghai', 'Huangpu', 'Residential', 2025, 1, 18500000, 85),
 ('Shenzhen', 'Nanshan', 'Residential', 2025, 1, 14200000, 100),
 ('Shenzhen', 'Futian', 'Residential', 2025, 1, 10800000, 130),
 ('Guangzhou', 'Tianhe', 'Residential', 2025, 1, 8500000, 140),
 ('Hangzhou', 'Xihu', 'Residential', 2025, 1, 9200000, 115),
 ('Chengdu', 'High-Tech Zone', 'Residential', 2025, 2, 6800000, 150),
 ('Nanjing', 'Gulou', 'Residential', 2025, 2, 7200000, 125),
 ('Wuhan', 'Wuchang', 'Residential', 2025, 2, 5500000, 160),
) AS t(city, district, property_type, year, month, price, area);
""")

# Data Source B: Social media discussion volume (from public APIs)
con.execute("""
CREATE TABLE social_trends AS
SELECT * FROM (VALUES
 ('Beijing', 8500), ('Beijing', 9200),
 ('Shanghai', 7800), ('Shanghai', 8100),
 ('Shenzhen', 6500), ('Shenzhen', 7200),
 ('Guangzhou', 4200), ('Guangzhou', 4800),
 ('Hangzhou', 5100), ('Hangzhou', 5600),
 ('Chengdu', 6200),
 ('Nanjing', 3800),
 ('Wuhan', 3500),
) AS t(city, mention_count);
""")

# Data Source C: Net population migration (from government statistics)
con.execute("""
CREATE TABLE migration_data AS
SELECT * FROM (VALUES
 ('Beijing', -12000),
 ('Shanghai', -8000),
 ('Shenzhen', 25000),
 ('Guangzhou', 18000),
 ('Hangzhou', 32000),
 ('Chengdu', 45000),
 ('Nanjing', 20000),
 ('Wuhan', 28000),
) AS t(city, net_migration);
""")

print("✅ Three data sources loaded successfully")

Step 2: SQL Analysis — Discovering Stories in the Data

This is where the magic happens. We use several SQL queries across three analytical layers:

Analysis A: Value Index Ranking

Find cities with high social buzz but relatively low housing prices — these are potential investment opportunity signals.

WITH city_stats AS (
 SELECT 
 city,
 AVG(price) / 10000 AS avg_price_wan,
 AVG(area) AS avg_area,
 COUNT(*) AS transaction_count,
 SUM(price) / 100000000 AS total_volume_yi
 FROM real_estate_transactions
 GROUP BY city
),
city_trends AS (
 SELECT 
 city,
 SUM(mention_count) AS total_mentions,
 AVG(mention_count) AS avg_mentions
 FROM social_trends
 GROUP BY city
)
SELECT 
 cs.city,
 ROUND(cs.avg_price_wan, 1) AS avg_price_wan,
 ROUND(cs.avg_area, 0) AS avg_area_sqm,
 ct.avg_mentions,
 cm.net_migration,
 -- Value Index = Social Buzz / Price
 -- Higher means "attention relative to price" is worth investing
 ROUND(ct.avg_mentions / NULLIF(cs.avg_price_wan, 0), 2) AS value_index
FROM city_stats cs
JOIN city_trends ct ON cs.city = ct.city
JOIN migration_data cm ON cs.city = cm.city
ORDER BY value_index DESC;

Output:

cityavg_price_wanavg_area_sqmavg_mentionsnet_migrationvalue_index
Chengdu680.01506200+45,0009.12
Wuhan550.01603500+28,0006.36
Hangzhou935.01145350+32,0005.72
Nanjing720.01253800+20,0005.28
Guangzhou865.01384500+18,0005.20
Beijing1706.01258850-12,0005.19
Shanghai1844.01277950-8,0004.31

Analysis B: Anomaly Detection — Auto-Discovered Story Leads

Using DuckDB’s PERCENT_RANK() window function to automatically identify cities with “price-buzz mismatch” anomalies.

WITH stats AS (
 SELECT 
 r.city,
 AVG(r.price) / 10000 AS avg_price,
 AVG(s.mention_count) AS avg_mentions,
 m.net_migration,
 PERCENT_RANK() OVER (ORDER BY AVG(r.price)) AS price_rank,
 PERCENT_RANK() OVER (ORDER BY AVG(s.mention_count)) AS mentions_rank
 FROM real_estate_transactions r
 JOIN social_trends s ON r.city = s.city
 JOIN migration_data m ON r.city = m.city
 GROUP BY r.city, m.net_migration
)
SELECT 
 city,
 ROUND(avg_price, 1) AS avg_price_wan,
 ROUND(avg_mentions, 0) AS avg_mentions,
 net_migration,
 ROUND(mentions_rank - price_rank, 2) AS anomaly_score,
 CASE 
 WHEN mentions_rank - price_rank > 0.3 THEN '🎯 High buzz, low price — Investment opportunity'
 WHEN mentions_rank - price_rank > 0 THEN '📊 Buzz exceeds price — Emerging hotspot'
 WHEN price_rank - mentions_rank > 0.3 THEN '💸 High price, low buzz — Bubble risk'
 ELSE '➖ Normal match'
 END AS story_angle
FROM stats
ORDER BY anomaly_score DESC;

Output:

cityanomaly_scorestory_angle
Chengdu0.43🎯 High buzz, low price — Investment opportunity
Beijing0.14📊 Buzz exceeds price — Emerging hotspot
Wuhan0.00➖ Normal match
Shanghai-0.14➖ Normal match

The key insight: cities with anomaly_score > 0 are story leads worth digging deeper into. Chengdu ranks first at 0.43 — lowest price tier nationally, second-highest social buzz, plus 45,000 net population inflow. That’s a powerful “value洼地” (undervalued market) signal.

Step 3: Auto-Generate Publishable Content

Convert SQL results directly into Markdown newsletter format:

def generate_newsletter(con):
 """Auto-generate data journalism newsletter"""
 
 # Get core analysis results
 results = con.execute("""
 WITH stats AS (
 SELECT 
 r.city,
 AVG(r.price) / 10000 AS avg_price,
 AVG(s.mention_count) AS avg_mentions,
 m.net_migration,
 PERCENT_RANK() OVER (ORDER BY AVG(r.price)) AS price_rank,
 PERCENT_RANK() OVER (ORDER BY AVG(s.mention_count)) AS mentions_rank
 FROM real_estate_transactions r
 JOIN social_trends s ON r.city = s.city
 JOIN migration_data m ON r.city = m.city
 GROUP BY r.city, m.net_migration
 )
 SELECT 
 city,
 ROUND(avg_price, 1) AS avg_price_wan,
 ROUND(avg_mentions, 0) AS avg_mentions,
 net_migration,
 ROUND(mentions_rank - price_rank, 2) AS anomaly_score,
 CASE 
 WHEN mentions_rank - price_rank > 0.3 THEN '🎯 High buzz, low price — Investment opportunity'
 WHEN mentions_rank - price_rank > 0 THEN '📊 Buzz exceeds price — Emerging hotspot'
 WHEN price_rank - mentions_rank > 0.3 THEN '💸 High price, low buzz — Bubble risk'
 ELSE '➖ Normal match'
 END AS story_angle
 FROM stats
 ORDER BY anomaly_score DESC
 """).fetchdf()
 
 # Build Markdown newsletter
 md = "# 🏠 Weekly City Real Estate Market Report\n\n"
 md += f"**Generated**: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}\n\n"
 md += "---\n\n"
 md += "## 🔥 Key Findings This Week\n\n"
 
 for _, row in results.iterrows():
 md += f"- **{row['city']}**: {row['story_angle']} (anomaly score: {row['anomaly_score']})\n"
 
 md += "\n---\n\n"
 md += "## 📊 Detailed Data\n\n"
 
 for _, row in results.iterrows():
 md += f"### {row['city']}\n\n"
 md += f"- Avg Price: {row['avg_price_wan']} wan\n"
 md += f"- Social Buzz: {int(row['avg_mentions'])} mentions\n"
 md += f"- Net Migration: {row['net_migration']:+,}\n"
 md += f"- Anomaly Score: {row['anomaly_score']}\n\n"
 
 return md

Part 4: Advanced — Connecting to Real Data Sources

The examples above use simulated data. In production, DuckDB seamlessly integrates with various real data sources:

Pull Remote APIs with the httpfs Extension

# Read CSV directly from a GitHub raw URL
con.execute("""
CREATE OR REPLACE VIEW government_data AS
SELECT * FROM read_csv_auto('https://data.example.gov/housing.csv')
""")

# Pull social media data from a JSON API
con.execute("""
CREATE OR REPLACE VIEW social_api_data AS
SELECT * FROM read_json_auto('https://api.example.com/trends?city=beijing')
""")

Read Parquet from S3 / Cloud Storage

con.sql("INSTALL httpfs; LOAD httpfs")
con.sql("""
SELECT * FROM s3_read_parquet(
 'https://bucket.s3.amazonaws.com/housing-data/*.parquet',
 access_key_id='YOUR_KEY',
 secret_access_key='YOUR_SECRET'
)
""")

Batch Process Multiple Files

# Read and auto-merge all CSVs in a directory
con.execute("""
CREATE TABLE all_transactions AS
SELECT * FROM read_csv_auto('data/transactions/*.csv', hive_partitioning=true)
""")

Part 5: Comparison with Traditional Tools

DimensionDuckDBExcelPython + PandasBI Tools
Learning Curve⭐ Minimal (SQL only)⭐ Minimal⭐⭐⭐ Requires coding⭐⭐ Moderate
Multi-Source Fusion✅ Native support❌ Manual import✅ Requires coding⚠️ Partial
Speed (million rows)<1 secondMinutes1-3 secondsDepends on backend
Automation✅ Scriptable❌ Manual✅ Scriptable⚠️ Limited
Deployment CostZero (local)ZeroZeroRequires server
BarrierLow (one person)LowMedium (tech needed)High (team needed)

DuckDB’s core advantage: it gives one person the combat power of a small data team.


Summary

The complete workflow for building a personal data journalism product with DuckDB:

  1. Define your topic — Pick a domain with your industry knowledge
  2. Collect public data — Government open data, APIs, web scraping
  3. SQL analysis & mining — Use DuckDB for correlation analysis and anomaly detection
  4. Auto-generate reports — Python scripts convert analysis results to Markdown
  5. Multi-channel distribution — Newsletter, self-media, paid communities
  6. Iterate continuously — Adjust data sources and models based on feedback

You can build an MVP over a weekend, then run the script weekly thereafter. That’s the superpower DuckDB gives individuals — one person is a data team.


Want to systematically learn how to build data journalism products with DuckDB? duckdblab.org has a complete tutorial series covering everything from data source ingestion to report automation — step by step to build your first data product.


本文信息

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

如发现错误,欢迎通过 GitHub Issue 或邮件 contact@duckdblab.org 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials