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 monetize (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
Monetization 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.


Part 6: Monetization Strategies — Turning Data Journalism into Revenue

1. Paid Subscription Newsletter

Publish your weekly real estate market reports on Substack, Knowledge Planet, or your own website with a paid subscription. A deep-dive data weekly in a vertical niche at ¥99/month is not unreasonable.

2. Data-Driven Self-Media Content

Turn analysis results into articles or short videos for Xiaohongshu, Bilibili, Douyin, etc. Data journalism has natural virality — “I used data to discover this city’s secret” is far more compelling than “I feel this city is nice.”

3. Data Consulting Services for Institutions

After building a portfolio of data journalism pieces, offer customized data analysis services to real estate agencies, investment firms, and developers. Individual consulting projects typically range from ¥5,000 to ¥50,000.

4. Build a Data Product SaaS

Wrap the entire pipeline into a simple web app (DuckDB + FastAPI + Streamlit) where users input their own data and get generated reports. That’s a micro-SaaS product.

Key Takeaways

  • Choose a domain you know well: The real moat of data journalism isn’t technology — it’s your industry expertise
  • Maintain update frequency: Weekly > Monthly > Daily (depending on data availability)
  • Build a data source network: Pre-organize all your public data source access methods into a personal “data map”

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.

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy

⚠️ This site is an independent community project, not affiliated with, endorsed by, or sponsored by the DuckDB Foundation or official DuckDB project.

"DuckDB" is a registered trademark of the DuckDB Foundation. This site uses the name solely for factual description purposes.

All content is for educational and community promotion purposes only and does not constitute any commercial service.