One Person, Eight Stores: How a DuckDB-Powered E-commerce Data Service Runs on Near-Zero Marginal Cost
Bottom line first: a data analyst I know (freelance) spent the past six months pivoting to “managed e-commerce data insights” — one person, 8 Tmall/Douyin stores, CNY 32,000/month fixed income, near-zero marginal cost. Tech stack: DuckDB + Python + crontab. This post is the full breakdown, including the SQL fixes you’ll need.
Why This Model Works
Demand side: 20M+ small e-commerce sellers in China, ~80% with no dedicated data team. They know they should track conversion rate, AOV, and repeat purchase — but they can’t build automated reporting. A full-time analyst costs CNY 12k/month (too expensive); manual Excel pulls take 1-2 hours/day and are error-prone.
Supply side: you build the “daily insight” as an automated product with DuckDB, charge per store, and serve 8-15 stores alone.

Pricing that’s been validated in the market:
| Tier | Price | Includes |
|---|---|---|
| Basic | CNY 800/month/store | One daily automated report (revenue, traffic, conversion) |
| Pro | CNY 2,000/month/store | 5 custom KPIs + anomaly alerts |
| Annual | CNY 18,000/year/store | ~25% discount, locks in cash flow |
8 pro + 4 basic stores ≈ CNY 19,200/month for one person. Add pre-sale-event “data sprint packs” (CNY 3,000/store/event, 2 weeks before 618/Double 11) and you add CNY 5k-15k in big-promo months.
Cost: one CNY 50/month VPS + 2 hours/day of maintenance. All 8 customer databases total under 1 GB.
Step 1: Multi-Source Ingestion
E-commerce data lives in four places: orders (backend CSV exports), traffic (Excel from platform dashboards), support chats (CRM JSON), inventory (ERP Excel). DuckDB’s key advantage: no data warehouse to ship data into — query the files where they are, with read_csv_auto / read_xlsx / read_json_auto.
import duckdb
from datetime import datetime
DB_PATH = "clients/client_a.duckdb"
RAW_DIR = "./raw_data" # seller drops export files here
con = duckdb.connect(DB_PATH)
# Ingestion 1: orders CSV
con.execute(f"""
CREATE OR REPLACE TABLE orders AS
SELECT * FROM read_csv_auto('{RAW_DIR}/orders_20260928.csv')
""")
# Ingestion 2: traffic Excel (business dashboard export)
con.execute(f"""
CREATE OR REPLACE TABLE traffic AS
SELECT * FROM read_xlsx('{RAW_DIR}/traffic_20260928.xlsx',
header=True, sheet='daily_trend')
""")
# Ingestion 3: support chat JSON
con.execute(f"""
CREATE OR REPLACE TABLE cs_chats AS
SELECT * FROM read_json_auto('{RAW_DIR}/cs_chats_20260928.json')
""")
# Ingestion 4: inventory Excel
con.execute(f"""
CREATE OR REPLACE TABLE inventory AS
SELECT * FROM read_xlsx('{RAW_DIR}/inventory_20260928.xlsx', header=True)
""")
print("OK - four tables ingested into DuckDB")
The key: sellers just drop 4 files in a folder; your script handles all formats without schema unification.
Step 2: The 5 KPIs Sellers Actually Look At
Principle: the report is at most 1 page, at most 5 metrics. Sellers want “understand the business in 3 minutes,” not a 30-page PPT.
- Yesterday’s revenue & day-over-day change
- Yesterday’s visitors & conversion rate
- Inventory alerts (SKUs below safety stock)
- Negative support tickets / complaints
- Competitor price monitoring (optional add-on)
KPI 1: revenue with day-over-day (LAG window):
WITH daily AS (
SELECT DATE(order_time) AS dt,
SUM(amount) AS revenue,
COUNT(*) AS order_cnt
FROM orders
GROUP BY DATE(order_time)
),
with_lag AS (
SELECT dt, revenue, order_cnt,
LAG(revenue, 1) OVER (ORDER BY dt) AS prev_revenue
FROM daily
)
SELECT dt, revenue, order_cnt, prev_revenue,
ROUND((revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0), 1)
AS revenue_change_pct,
CASE
WHEN (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0) < -10 THEN 'DOWN >10%'
WHEN (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0) > 10 THEN 'UP >10%'
ELSE 'normal'
END AS alert
FROM with_lag
WHERE dt = (SELECT MAX(dt) FROM daily);
Sample run (demo data):
┌─────────────┬─────────┬───────────┬──────────────┬────────────────────┐
│ dt │ revenue │ order_cnt │ prev_revenue │ revenue_change_pct │
├─────────────┼─────────┼───────────┼──────────────┼────────────────────┤
│ 2026-09-28 │ 388.0 │ 2 │ 318.0 │ 22.0 │
└─────────────┴─────────┴───────────┴──────────────┴────────────────────┘
→ alert: UP >10%
KPI 2: 7-day visitors & conversion rate:
SELECT
DATE(t.stat_date) AS dt,
t.visitor_cnt,
COALESCE(o.order_cnt, 0) AS order_cnt,
ROUND(COALESCE(o.order_cnt, 0) * 100.0 / NULLIF(t.visitor_cnt, 0), 2)
AS conversion_rate
FROM (SELECT DATE(stat_date) AS stat_date, SUM(visitor_cnt) AS visitor_cnt
FROM traffic GROUP BY DATE(stat_date)) t
LEFT JOIN (SELECT DATE(order_time) AS dt, COUNT(*) AS order_cnt
FROM orders GROUP BY DATE(order_time)) o
ON t.stat_date = o.dt
ORDER BY t.stat_date DESC
LIMIT 7;
KPI 3: inventory alerts:
SELECT sku, product_name, stock, safety_stock,
CASE
WHEN stock < safety_stock THEN 'ALERT: below safety stock'
WHEN stock < safety_stock * 1.5 THEN 'WARNING: running low'
ELSE 'OK'
END AS status
FROM inventory
WHERE stock < safety_stock * 2
ORDER BY (stock - safety_stock) ASC;
Step 3: Anomaly Detection — The “Worth It” Feature
Three rules cover nearly every “something’s wrong with the business” scenario: 3 consecutive down days, single-day drop >30%, or below 7-day mean by 2 standard deviations.
The original channel SQL had two bugs (a mis-referenced LAG and MAX(dt) - INTERVAL applied to a date column directly). Here is the corrected, tested version:
WITH daily AS (
SELECT DATE(order_time) AS dt, SUM(amount) AS revenue
FROM orders
GROUP BY DATE(order_time)
),
trend AS (
SELECT dt, revenue,
AVG(revenue) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND 1 PRECEDING) AS avg7,
STDDEV(revenue) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND 1 PRECEDING) AS std7,
LAG(revenue, 1) OVER (ORDER BY dt) AS prev_revenue,
LAG(revenue, 2) OVER (ORDER BY dt) AS prev2
FROM daily
)
SELECT dt, revenue,
CASE
-- 3 consecutive down days
WHEN prev2 IS NOT NULL
AND prev_revenue < prev2
AND revenue < prev_revenue
THEN 'DOWN_3_CONSECUTIVE'
-- single-day drop > 30%
WHEN prev_revenue > 0
AND (revenue - prev_revenue) * 100.0 / prev_revenue < -30
THEN 'SHARP_DROP_30PCT'
-- below 7-day mean by 2 std dev
WHEN std7 IS NOT NULL AND revenue < avg7 - 2 * std7
THEN 'BELOW_MEAN_2SD'
ELSE 'NORMAL'
END AS anomaly_type
FROM trend
WHERE dt >= (SELECT MAX(dt) FROM daily) - INTERVAL 7 DAY
ORDER BY dt DESC;
Stock-out risk (sales velocity vs. stock coverage):
The original query used WHERE i.stock < COALESCE(s.sales_7d, 0) after a LEFT JOIN — when a SKU has zero sales in 7 days, sales_7d is NULL, COALESCE(...,0) makes the condition always false, and you silently miss slow-moving overstock. Practical fix:
SELECT i.sku, i.product_name, i.stock,
COALESCE(s.sales_7d, 0) AS sales_7d,
CASE
WHEN i.stock < COALESCE(s.sales_7d, 0) * 0.3 THEN 'RISK: covers <3 days'
WHEN i.stock < COALESCE(s.sales_7d, 0) * 0.7 THEN 'RISK: covers <1 week'
ELSE 'OK'
END AS risk
FROM inventory i
LEFT JOIN (SELECT sku, SUM(quantity) AS sales_7d
FROM orders
WHERE order_time >= CURRENT_DATE - INTERVAL 7 DAY
GROUP BY sku) s
ON i.sku = s.sku
WHERE s.sales_7d IS NULL -- slow mover: zero sales in 7d
OR i.stock < COALESCE(s.sales_7d, 0); -- stock can't cover recent sales
Two WHERE branches together: “selling too fast, stock runs out” and “not selling, stock rotting” — the latter is exactly what sellers don’t want to see but will pay to be warned about.
Step 4: Generate and Deliver the Daily Report
Reports stay under 6-8 lines, sized for WeCom/WeChat/Telegram:
def generate_daily_insight_report(con, client_name="Client A", today=None):
"""today is a 'YYYY-MM-DD' string — pass it explicitly to avoid timezone issues."""
core = con.execute("""
SELECT
SUM(CASE WHEN DATE(order_time) = ? THEN amount END) AS today_revenue,
SUM(CASE WHEN DATE(order_time) = ? - INTERVAL 1 DAY THEN amount END) AS yest_revenue
FROM orders
""", [today, today]).fetchone()
today_rev, yest_rev = (core[0] or 0), (core[1] or 0)
rev_change = (today_rev - yest_rev) * 100.0 / max(yest_rev, 1)
inv_alerts = con.execute("""
SELECT product_name, stock, safety_stock
FROM inventory WHERE stock < safety_stock
ORDER BY stock ASC LIMIT 3
""").fetchdf()
lines = [
f"📊 {client_name} Daily Insight · {today[5:10]}",
f"💰 Today: CNY {today_rev:,.0f} ({abs(rev_change):.1f}% {'up' if rev_change>=0 else 'down'} vs yesterday)",
]
if rev_change < -15:
lines += ["", "🚨 Revenue anomaly",
" Check: traffic drop / conversion anomaly / hot SKU out of stock"]
if len(inv_alerts) > 0:
lines += ["", "📦 Inventory alerts"]
for _, row in inv_alerts.iterrows():
lines.append(f" • {row['product_name']}: {int(row['stock'])} left (safety line {int(row['safety_stock'])})")
lines += ["", "✅ Sources: orders / traffic / support / inventory auto-aggregated"]
return "\n".join(lines)
Sample output (demo data):
📊 Client A Daily Insight · 09-28
💰 Today: CNY 388 (22.0% up vs yesterday)
📦 Inventory alerts
• Face mask: 12 left (safety line 50)
• Serum: 40 left (safety line 100)
✅ Sources: orders / traffic / support / inventory auto-aggregated
Step 5: Multi-Tenant Batch Architecture
One .duckdb file per client (typically <100 MB); 8 clients <1 GB total. One CNY 50/month VPS + crontab hosts everything:
import schedule, os, duckdb
from datetime import datetime
CLIENTS = [
{"id": "client_a", "name": "Client A", "db": "./clients/client_a.duckdb", "tier": "pro"},
{"id": "client_b", "name": "Client B", "db": "./clients/client_b.duckdb", "tier": "basic"},
]
def process_client(client):
con = duckdb.connect(client["db"])
today = datetime.now().strftime('%Y-%m-%d')
if client["tier"] == "pro":
report = generate_daily_insight_report(con, client["name"], today)
else:
core = con.execute(
"SELECT COALESCE(SUM(amount),0), COUNT(*) FROM orders WHERE DATE(order_time) = ?",
[today]).fetchone()
report = f"📊 {client['name']}\n💰 Today: CNY {core[0]:,.0f}, {core[1]} orders OK"
os.makedirs("./reports", exist_ok=True)
with open(f"./reports/{client['id']}_{today.replace('-','')}.txt", 'w') as f:
f.write(report)
print(f"OK - {client['name']} report generated")
def daily_batch_run():
for client in CLIENTS:
try:
process_client(client)
except Exception as e:
print(f"FAIL {client['name']}: {e}")
print(f"OK - batch complete, {len(CLIENTS)} clients")
schedule.every().day.at("08:00").do(daily_batch_run)
# Production: 0 8 * * * cd /path/to/project && python daily_batch.py >> run.log 2>&1
DuckDB vs. Traditional Stacks
| Dimension | Manual Excel | Cloud DWH + BI | DuckDB service |
|---|---|---|---|
| Data ingestion per store | Copy-paste, 1-2h/day | Sync pipelines, hours to set up | Files land, query in seconds |
| Anomaly alerts | None (human eyeballs) | Extra paid alerting module | Built-in window functions, 0 extra cost |
| Marginal cost per client | Your time × clients | Seat fees, thousands/month | Near-zero (file = database) |
| Deployment | Office on each machine | Cloud account + IAM | Single binary + crontab |
| Clients per operator | 3-5 (drowns in work) | 5-8 (heavy ops load) | 8-15 |
| Monthly cost (ref) | ¥0 tooling + labor | CNY 2k-8k/seat/mo | CNY 50 VPS |
The model only works because marginal cost barely grows with client count.
💰 Monetization Playbook
- Tonight: run the full data flow in Jupyter with mock data, then offer one acquaintance’s store a free one-week pilot and collect feedback.
- Pricing: sell “understand your business in 3 minutes a day.” Start at CNY 800/month basic; 50% off the first 3 clients to build case studies fast.
- Income structure (solo, 8 pro + 4 basic):
- Fixed: 8 × CNY 2,000 + 4 × CNY 800 = CNY 19,200/month
- Big-promo add-ons: “data sprint pack” CNY 3,000/store in the 2 weeks before 618/Double 11 → +CNY 5k-15k in promo months
- Annual total: CNY 280k-330k for one person
- Scale-up paths:
- Add-on: custom KPI development at CNY 500/metric (one-time)
- Upsell: auto-formatted charts (DuckDB
CREATE MACRO+ simple SVG export) lifts ARPU 20-30% - Beyond 15 clients: wrap ingestion into a small CLI (
duckdb-ingest) to cut onboarding from 2 hours to 10 minutes
The core logic: you’re not selling “data analysis,” you’re selling “3 minutes a day, the business doesn’t fall over.” Sellers pay for certainty, not for technology. DuckDB makes that certainty cost a CNY 50 VPS.
📖 More DuckDB practical monetization case studies → duckdblab.org