{"slug": "materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure", "title": "Materialized view patterns, trade-offs, and when to use each on SQL Server/Azure SQL and .NET", "summary": "A developer outlines three materialized view patterns for SQL Server/Azure SQL and .NET: indexed views, custom read model tables, and precomputed page caches. The post explains how each pattern improves pagination performance by reducing runtime joins and enabling perfect index alignment for cursor-based pagination. A design playbook for 10M+ rows recommends custom read model tables for heavy writes and indexed views for stable aggregations.", "body_md": "#\nWhat do we mean by “materialized view” on Azure SQL?\n\nSQL Server/Azure SQL doesn’t have Oracle-style materialized views; the closest native feature is an **Indexed View** (schema-bound view with a clustered index). In practice, teams use three flavors:\n\n#\nSummary and key points\n\n-\n**Materialized read models (indexed views or custom projection tables)** absolutely help large-scale pagination by **removing runtime work** and enabling **perfect indexes** for your cursor pattern.\n- Use\n**custom read models + outbox** for maximum control and minimal read latency; use **indexed views** when you want SQL Server to maintain a specific aggregation/join.\n- Keep\n**seek/cursor pagination**—it’s the core scalability lever. Materialization just makes each page cheaper.\n- Add\n**Redis page/window caching** for super-hot lists, and move faceted text search to **Azure AI Search/Elasticsearch** if needed.\n\n##\n1. Indexed View (native)\n\n-\n**What it is:** A `VIEW ... WITH SCHEMABINDING`\n\n+ **clustered index** that stores view rows physically.\n-\n**When it shines:** Expensive joins/aggregations that are stable and heavily reused.\n-\n**Impact on pagination:** You paginate **over the view** with a covering index aligned to your sort key, so the engine skips the big base-table joins at runtime.\n-\n**Costs/constraints:** Write penalty (maintained on every insert/update), schema rules (determinism, no `*`\n\n, etc.).\n\n##\n2. Materialized Read Model Table (custom)\n\n-\n**What it is:** A **denormalized table** that you keep in sync (CQRS projection). Often called a “projection,” “read model,” or “summary table.”\n-\n**Sync options:**\n\n-\n**Streaming** via outbox + background dispatcher (near-real-time, strong control).\n-\n**CDC/Change Tracking** + ETL job (near-real-time or batch).\n\n**Impact on pagination:** You tailor the table to the exact API shape, add a **clustered index on **`(SortKey, Id)`\n\nand include only the columns needed by the list page → extremely fast keyset pagination.\n\n**Costs:** Extra storage + write/update path. You must design rebuild/backfill procedures.\n\n##\n3. Precomputed Page Cache (ephemeral)\n\n-\n**What it is:** Cache **page windows** (e.g., first 50, next 50 cursors) in Redis keyed by filter + sort + cursor.\n-\n**Impact:** Removes repeat read costs for hot feeds and “first page” traffic.\n-\n**Costs:** Cache invalidation; combinatorial explosion for many filter combos (use selectively).\n\n#\nDo they improve pagination performance?\n\n**Yes, by shrinking the query work per page**:\n\n- No/less joining at runtime.\n- Narrow, page-friendly rows (no wide payload).\n- Perfectly\n**aligned indexes** for your sort/filter.\n- Fewer logical reads, lower CPU, and better P95/P99.\n\nBut remember: **seek/cursor pagination** is still required for huge data. Materialization won’t fix OFFSET/FETCH’s deep-page slowness.\n\n#\nDesign playbook (10M+ rows)\n\n##\n1) Choose your read model\n\n-\n**If your list endpoint needs multiple joins, computed fields, or rollups:**\nUse a **custom read model table** or **indexed view**.\n-\n**If writes are heavy and latency tolerance is low (OLTP):**\nPrefer a **read model table** updated asynchronously (outbox/CQRS). Indexed views add write latency.\n\n##\n2) Shape for pagination\n\n- Store exactly what the endpoint needs (no N+1 lookups).\n-\n**Clustered index:** `(SortKey, Id)`\n\nin the same direction you present (often `DESC`\n\nfor newest-first).\n-\n**Covering index:** If the clustered key differs (for example, you cluster by `Id`\n\nfor other reasons), add a **nonclustered** index on `(SortKey, Id) INCLUDE (<DTO columns>)`\n\n.\n\n##\n3) Keep it fresh\n\n##\n4) Paginate with a cursor (seek)\n\n- Stable order (e.g.,\n`CreatedAtUtc DESC, Id DESC`\n\n).\n- Seek predicate using last item’s\n`(SortKey, Id)`\n\nfrom a **signed **`pageToken`\n\n.\n-\n**Projection to DTO** in the query (don’t materialize entities).\n\n##\n5) Validate filters & align indexes\n\n- Whitelist filter fields.\n- If you commonly filter by\n`Status`\n\n, build `(Status, SortKey, Id)`\n\nindex and **INCLUDE** the display columns.\n\n#\nAlternatives & complements\n\n###\nA) **Indexed View** vs **Read Model Table**\n\n-\n**Indexed View**: Zero custom sync code; SQL Server maintains it. Great for **deterministic aggregates**. But it **taxes writes** and is harder to evolve.\n-\n**Read Model Table**: Maximum control, **cheapest reads**, and you can store denormalized JSON or precomputed projections. Needs a projector (worker) and backfill logic.\n\n###\nB) **Search engine for faceted filtering**\n\n###\nC) **Partitioning & storage options**\n\n-\n**Range partitioning** on date can keep working sets small (monthly tables or partition function).\n-\n**Columnstore** is excellent for analytics scans, but **not ideal** for cursor pagination of OLTP feeds; prefer rowstore + narrow covering indexes.\n\n###\nD) **Cosmos DB**\n\n- If your data is already in Cosmos, create a\n**projection container** tailored to the list shape; use **SDK continuation tokens** and `ORDER BY createdAt, id`\n\nwith partition-aligned queries.\n\n#\nWhen *not* to use materialization\n\n- If the base list is already a single table with a perfect covering index and no computed fields, adding a read model won’t move the needle much—the\n**seek query is already optimal**.\n- If write throughput is extreme and the projection would add unacceptable write amplification, prefer\n**on-the-fly** with careful indexing or consider **eventual consistency** projections for read paths that can tolerate lag.\n\n#\nPractical .NET implementation sketch\n\n**Outbox + projector (EF Core 9)**\n\n- In your command handler, write domain changes\n**and** append `OutboxEvent`\n\nin the same transaction.\n- A hosted service (or Function/Worker) polls unsent events and\n`UPSERT`\n\ns the **ReadModel_Items** table.\n- The list endpoint queries\n**ReadModel_Items** with the **seek** pattern and emits a signed `nextPageToken`\n\n.\n\n**SQL for read model**\n\n**Endpoint (pseudo)**", "url": "https://wpnews.pro/news/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure", "canonical_source": "https://dev.to/hossein_esmati/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-serverazure-sql-and-net-1c95", "published_at": "2026-06-26 11:51:38+00:00", "updated_at": "2026-06-26 12:05:04.065239+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["SQL Server", "Azure SQL", ".NET", "Redis", "Azure AI Search", "Elasticsearch", "CQRS", "CDC"], "alternates": {"html": "https://wpnews.pro/news/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure", "markdown": "https://wpnews.pro/news/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure.md", "text": "https://wpnews.pro/news/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure.txt", "jsonld": "https://wpnews.pro/news/materialized-view-patterns-trade-offs-and-when-to-use-each-on-sql-server-azure.jsonld"}}