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:
- Find public data (government open data, APIs, web scraping)
- Analyze correlations with SQL (DuckDB’s sweet spot)
- Discover anomalous patterns (this is your “story”)
- Automate report generation (Markdown / PDF / Newsletter)
- 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:
| city | avg_price_wan | avg_area_sqm | avg_mentions | net_migration | value_index |
|---|---|---|---|---|---|
| Chengdu | 680.0 | 150 | 6200 | +45,000 | 9.12 |
| Wuhan | 550.0 | 160 | 3500 | +28,000 | 6.36 |
| Hangzhou | 935.0 | 114 | 5350 | +32,000 | 5.72 |
| Nanjing | 720.0 | 125 | 3800 | +20,000 | 5.28 |
| Guangzhou | 865.0 | 138 | 4500 | +18,000 | 5.20 |
| Beijing | 1706.0 | 125 | 8850 | -12,000 | 5.19 |
| Shanghai | 1844.0 | 127 | 7950 | -8,000 | 4.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:
| city | anomaly_score | story_angle |
|---|---|---|
| Chengdu | 0.43 | 🎯 High buzz, low price — Investment opportunity |
| Beijing | 0.14 | 📊 Buzz exceeds price — Emerging hotspot |
| Wuhan | 0.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
| Dimension | DuckDB | Excel | Python + Pandas | BI Tools |
|---|---|---|---|---|
| Learning Curve | ⭐ Minimal (SQL only) | ⭐ Minimal | ⭐⭐⭐ Requires coding | ⭐⭐ Moderate |
| Multi-Source Fusion | ✅ Native support | ❌ Manual import | ✅ Requires coding | ⚠️ Partial |
| Speed (million rows) | <1 second | Minutes | 1-3 seconds | Depends on backend |
| Automation | ✅ Scriptable | ❌ Manual | ✅ Scriptable | ⚠️ Limited |
| Deployment Cost | Zero (local) | Zero | Zero | Requires server |
| Monetization Barrier | Low (one person) | Low | Medium (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:
- Define your topic — Pick a domain with your industry knowledge
- Collect public data — Government open data, APIs, web scraping
- SQL analysis & mining — Use DuckDB for correlation analysis and anomaly detection
- Auto-generate reports — Python scripts convert analysis results to Markdown
- Multi-channel distribution — Newsletter, self-media, paid communities
- 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.