orm je na to špatný nástroj
Django ORM je skvělý pro transakční aplikační kód, CRUD obrazovky, permission filtry a business logiku, kde je důležitější čitelnost než ždímat z query planneru poslední procenta výkonu. Reporting je ale úplně jiná práce. Ve chvíli, kdy query potřebuje pět joinů, partitioned rank, klouzavou 30denní metriku, podmíněné agregace a pár chytře umístěných filtrů, aby se nic nezapočítalo dvakrát, je snaha vyjádřit to jako řetěz QuerySetů v podstatě malá forma sebepoškozování.
Viděli jsme codebase, kde měsíční revenue report seděl v manager metodě s dvanácti annotate() voláními, třemi zanořenými Subquery(), OuterRef() létal všude kolem a finální .values() potichu změnilo groupování, protože při refaktoru přibyl jeden sloupec navíc. Nikdo na to nechtěl sahat, všichni se toho báli a vygenerované SQL vypadalo, jako by se ORM pohádalo samo se sebou. Django přesně udělalo to, co jste po něm chtěli. Právě to je ten problém.
SQL už má pojmy pro věci, které reporting potřebuje. Common table expressions vám dovolí rozdělit query na kroky, které člověk přečte. Window functions spočítají ranky, klouzavé průměry nebo percentily bez toho, abyste tahali půl datasetu do Pythonu. FILTER (WHERE ...) je čistší než cpát Case a When do každé agregace. date_trunc, generate_series, lag, lead, dense_rank, sum(...) over (...), to všechno existuje z nějakého důvodu.
Spousta týmů bere raw SQL jako nebezpečný escape hatch, ale bez mrknutí oka udržuje 200 řádků ORM zaklínadel, kterým rozumí jeden jediný člověk. To máte obráceně. Pro reporting a analytiku je SQL hlavní jazyk. Django je vrstva kolem, která to napojí do aplikace.
pište query, které opravdu myslíte
Začněte v SQL souboru, ne v Pythonu, ne v model manageru a už vůbec ne reverzním inženýrstvím toho, co náhodou vypíše str(queryset.query). Napište query tak, jak ho chce databáze vykonat, a verzujte ho jako normální kód. Pro sales funnel report bývá něco takového skoro vždy čitelnější než jakýkoli ORM ekvivalent:
1with base_orders as (2 select3 o.id,4 o.customer_id,5 o.created_at::date as order_date,6 o.total_cents,7 o.status8 from orders o9 where o.created_at >= current_date - interval '90 days'10),11paid_orders as (12 select *13 from base_orders14 where status = 'paid'15),16daily_totals as (17 select18 order_date,19 count(*) as paid_order_count,20 sum(total_cents) as revenue_cents21 from paid_orders22 group by order_date23),24series as (25 select generate_series(26 current_date - interval '89 days',27 current_date,28 interval '1 day'29 )::date as day30)31select32 s.day,33 coalesce(d.paid_order_count, 0) as paid_order_count,34 coalesce(d.revenue_cents, 0) as revenue_cents,35 sum(coalesce(d.revenue_cents, 0)) over (36 order by s.day37 rows between 6 preceding and current row38 ) as rolling_7d_revenue_cents39from series s40left join daily_totals d on d.order_date = s.day41order by s.day;
Tohle přečtete shora dolů, upravíte jednu část bez rozbití zbytku a můžete pustit EXPLAIN (ANALYZE, BUFFERS) přímo v psql nebo TablePlus. To je podstatné. Query, které neumíte pořádně zkontrolovat, neumíte ani ladit.
V Postgres 15 je tohle navíc příjemnější, protože planner se kolem CTE chová rozumněji než dřív, kdy se jim lidi vyhýbali ze strachu z vynucené materializace. Pořád musíte rozumět execution planům, pořád potřebujete správné indexy a pořád musíte sledovat row estimates. Jen už nemusíte za čitelnost automaticky platit.
Ve Steezru jsme tohle dělali na ERP dashboardech i zákaznických portálech, kde aplikace běží v Django, API je čisté, produktový tým dostane metriky, které chce, a nikdo nemusí během production incidentu debugovat řetěz annotate().
shipujte sql přes migrace
Když na query záleží, patří pod version control a stejnou deployment disciplínu jako změny schématu. Když raw SQL hodíte do helper stringu v Pythonu, nikdo neví, kdy se změnilo, nikdo ho pořádně nereviewuje a rollback se rychle zvrhne. Django migrace jsou úplně v pohodě místo pro views, materialized views, SQL funkce i podpůrné indexy.
Jednoduchý pattern je migrations.RunSQL plus SQL soubor v repu, načtený přes Path.read_text(). Třeba takhle:
1from pathlib import Path2from django.db import migrations34BASE_DIR = Path(__file__).resolve().parent56class Migration(migrations.Migration):7 dependencies = [8 ("reporting", "0012_previous"),9 ]1011 operations = [12 migrations.RunSQL(13 sql=(BASE_DIR / "sql" / "daily_revenue_mv.up.sql").read_text(),14 reverse_sql=(BASE_DIR / "sql" / "daily_revenue_mv.down.sql").read_text(),15 ),16 ]
A SQL soubor:
1create materialized view reporting_daily_revenue as2with paid_orders as (3 select4 created_at::date as day,5 total_cents6 from orders7 where status = 'paid'8)9select10 day,11 count(*) as order_count,12 sum(total_cents) as revenue_cents13from paid_orders14group by day;1516create unique index reporting_daily_revenue_day_idx17 on reporting_daily_revenue (day);
Ten unique index není volitelný, pokud chcete REFRESH MATERIALIZED VIEW CONCURRENTLY reporting_daily_revenue;. Bez něj na vás Postgres vyplivne ERROR: cannot refresh materialized view "reporting_daily_revenue" concurrently a řekne vám, že potřebuje použitelný unique index. Správně. Databáze vás chrání před provozně špatným rozhodnutím.
Pokud chcete podporu pro serializery a integraci do adminu, dejte nad view nebo materialized view unmanaged Django model:
1class DailyRevenue(models.Model):2 day = models.DateField(primary_key=True)3 order_count = models.IntegerField()4 revenue_cents = models.BigIntegerField()56 class Meta:7 managed = False8 db_table = "reporting_daily_revenue"
Tohle rozdělení funguje dobře. Django pořád řídí aplikaci. Postgres řídí report.
testujte sql jako kód
Týmy SQL testy vynechávají a pak jsou překvapené, když nenápadná změna schématu potichu rozbije revenue report na tři týdny. Jestli report řídí billing, finance, provize nebo čísla, která vidí zákazník, testujte ho přímo v databázi. pgTAP je dost dobrý, vyzrálý a o dost lepší než předstírat, že unit testy kolem ORM wrapperů pokrývají skutečné riziko.
pgTAP test umí ověřit počty řádků, přesné agregace, práci s null hodnotami i tvar view. I něco takhle jednoduchého chytí reálné chyby:
1begin;2select plan(4);34insert into orders (id, created_at, total_cents, status)5values6 (1, '2025-01-01 10:00:00+00', 5000, 'paid'),7 (2, '2025-01-01 12:00:00+00', 2500, 'paid'),8 (3, '2025-01-01 14:00:00+00', 9000, 'failed');910refresh materialized view reporting_daily_revenue;1112select is(13 (select order_count::int from reporting_daily_revenue where day = '2025-01-01'),14 2,15 'counts only paid orders'16);1718select is(19 (select revenue_cents::bigint from reporting_daily_revenue where day = '2025-01-01'),20 7500,21 'sums paid revenue correctly'22);2324select ok(25 not exists(select 1 from reporting_daily_revenue where revenue_cents is null),26 'revenue_cents is never null'27);2829select has_materialized_view('public', 'reporting_daily_revenue');3031select * from finish();32rollback;
Pouštějte to v CI proti Postgres 15, tedy stejné major verzi, jakou máte v produkci. Neodbývejte to test runem nad SQLite a netvařte se, že je hotovo. SQLite vám klidně nechá projít špatné předpoklady, protože se nechová jako Postgres v groupování, typech, JSON operátorech, časových zónách ani query planningu.
pgTAP snadno napojíte do GitHub Actions, stačí mít v test database imagi nainstalovaný pgtap a pouštět pg_prove. Týmy, které jsou zvyklé na Django test runner, si klidně můžou nechat Python integrační testy pro chování endpointů, permission logiku a serializaci, a správnost SQL vrstvy nechat na pgTAP. Je to čistší hranice než snažit se emulovat databázovou sémantiku v Python fixtures.
materialized views jsou ops rozhodnutí
Materialized views řeší jeden problém, drahé opakované čtení, a přidávají několik dalších, zastaralá data, cenu refreshe, chování locků, růst storage a údržbu, kterou někdo musí vlastnit. Používejte je tehdy, když ten trade-off dává smysl, ne proto, že to zní chytře.
Obyčejné view stačí, pokud podkladová query běží za 40 ms a access pattern je rozumný. Materialized view začne dávat smysl ve chvíli, kdy zdrojová query pálí CPU, joinuje velké tabulky nebo krmí dashboard, na který každých pár sekund sahá sales, podpora a nějaký wallboard, který si někdo pověsil v kanceláři. U jednoho klienta jsme nahradili hluboce zanořený ORM report, který na PostgreSQL 15.4 trval 6 až 9 sekund, materialized view refreshem každých pět minut. API spadlo zhruba na 30 ms a nikomu nevadilo, že čísla mají malé zpoždění, protože šlo o provozní trendy, ne burzovní obchodování.
Refresh strategie je důležitá. REFRESH MATERIALIZED VIEW vezme na view exkluzivní lock pro čtení. REFRESH MATERIALIZED VIEW CONCURRENTLY neblokuje čtenáře, trvá déle a vyžaduje ten unique index. Když refresh čas začne růst, možná budete muset agregovat dřív, partitionovat zdrojové tabulky nebo přestat předstírat, že jedno obří materialized view má odpovědět na každou reportingovou otázku.
Důležitý je i vacuum. Analytické workloady vytvářejí dead tuples na často updatovaných zdrojových tabulkách a zastaralé statistiky ničí rozhodování planneru. Výchozí autovacuum nastavení bývá pro vytížené SaaS produkty moc opatrné. Sledujte pg_stat_user_tables, n_dead_tup, last_autovacuum i reálný bloat. Když se report po růstové špičce najednou zpomalí, neobviňujte hned tvar SQL, nejdřív se podívejte, jestli planner nerozhoduje podle odpadních statistik.
Nad tím vším je ještě cache. Když se stejný report pálí pořád dokola, cacheujte API response v Redis. U multi-tenant aplikace klidně cache per tenant, pokud cardinalita dává smysl. TTL držte v souladu s tím, jak často refreshujete materialized view. Cacheovat query, které je při každém requestu jiné, je cargo cult. Cacheovat stabilní dashboard summary je prostě praktické.
udržte tu hranici čistou
Pragmatické rozdělení je jednoduché. Transakční business logika zůstává v Django modelech, service vrstvách a request handlerech. Těžká analytika žije v SQL artefaktech, které můžete reviewovat, testovat a ladit přímo v databázi. Problémy začínají ve chvíli, kdy týmy tuhle hranici rozmažou a nutí jednu vrstvu předstírat, že je tou druhou.
Dobré pravidlo je jednoduché: pokud kód existuje hlavně proto, aby poskládal řádky do reportu, zvlášť když v tom jsou časové buckety, cohort logika, ranking, deduplikace nebo agregace, pište SQL. Pokud kód existuje hlavně proto, aby vynucoval chování aplikace, skládal doménová pravidla, validoval změny stavu nebo orchestruje workflow, nechte ho v Pythonu. Tahle hranice stárne dobře, protože každá strana používá to, v čem je přirozeně silná.
Nic z toho neznamená, že máte z Django aplikace udělat data warehouse projekt. Nepotřebujete dbt kvůli každému dashboardu ve startupu, nepotřebujete Kafka jen proto, že je jeden report pomalý, a už vůbec nemusíte mučit ORM, aby vyjádřilo sum(total_cents) filter (where status = 'paid') over (partition by customer_id order by created_at), jen protože vám raw SQL ideologicky nevoní. Ideologii si nechte na conference talky.
Pište SQL do souborů, deployujte ho přes migrace, vystavte ho přes unmanaged modely nebo cursor() tam, kde to dává smysl, dejte kolem kritických částí pgTAP a refresh sémantiku popište normálně lidsky, aby produkt i finance věděly, jestli je graf real time, po pěti minutách, nebo podle včerejší uzávěrky. Inženýři tohle zvládnou. CTO by to měli vyžadovat. Reporting stack, který nikdo neumí přečíst, je problém dávno předtím, než se z něj stane scaling problém.
