Skip to content

View Selection Guide: Choosing Between v_* and tv_*

Overview

In FraiseQL v1, every GraphQL read resolves against a PostgreSQL view that returns a data JSONB column. There are two view patterns to choose from, and the choice is the single biggest lever for read performance. This guide helps you pick the right one.

Pattern Type Storage Use Case Latency Maintenance
v_* Logical view None Simple GraphQL queries Medium (100-500ms) None
tv_* Table-backed projection JSONB table Complex nested GraphQL Fast (50-200ms) Trigger/scheduled refresh

A v_* view composes its data JSONB at query time with a plain SELECT. A tv_* view is a real table holding pre-composed JSONB, refreshed by triggers or a scheduled job — you pay storage and refresh cost up front to make reads fast and predictable.

Both are bound to a GraphQL type the same way, at runtime, when the app starts:

import fraiseql
from fraiseql.types import ID

@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
    id: ID
    name: str
    email: str

Switching a type from a logical view to a table-backed projection is just a one-line change to sql_source — clients never see the difference.

Decision Tree

START: How complex is the read?

├─ Simple query? (1-2 tables, flat structure)
│  └─ YES → Use v_*
│        Why: No JOIN overhead, nothing to maintain
│        Example: Query a single user or a user list
│
└─ Complex query? (3+ tables, nested data)
   ├─ HIGH read volume (>100 reqs/sec)?
   │  └─ Use tv_* (table-backed projection)
   │     Why: Pre-composed JSONB, sub-second latency
   │     Example: User profiles with posts/comments
   │
   └─ LOW read volume (<100 reqs/sec)?
      ├─ Query time > 1 second?
      │  └─ Use tv_* (table-backed projection)
      │     Why: Composition cost exceeds storage cost
      │
      └─ Query time < 1 second?
         └─ Use v_* (logical view)
            Why: Storage overhead not justified

Quick Reference by Scenario

**Simple Cases (Use v_*)**:

  • ✅ Query single user by ID → v_user
  • ✅ Query list of posts → v_post
  • ✅ Query user with one related entity → v_user (no deep nesting)

**Complex Cases (Use tv_*)**:

  • ✅ User profile with posts, comments, and likes
  • ✅ Order with line items, customer, and shipment
  • ✅ Dashboard requiring pre-aggregated data
  • ✅ GraphQL subscriptions (real-time updates)

Performance Comparison Matrix

Query Execution Time (Lower is Better)

Query Type v_* tv_*
Single entity (User by ID) 50-100ms 50-100ms
Entity with 1 related (User + Posts) 100-300ms 100-200ms
Entity with 3+ related (User + Posts + Comments + Likes) 2-5s 50-200ms

Memory Usage (Lower is Better)

Scenario v_* tv_*
1K records with deep nesting 50-100MB 20-30MB
10K records with deep nesting 500-800MB 100-200MB

Storage Overhead (Lower is Better)

Pattern Overhead Notes
v_* 0% No storage (logical view)
tv_* 20-50% JSONB pre-composition stored in a table

When to Migrate

Migrate from v_* to tv_* when

✅ GraphQL query times exceed 1 second

  • Measure: Run production queries and log execution time
  • Action: Create a tv_* table with pre-composed JSONB
  • Benefit: 10-50x faster queries

✅ Query complexity has 3+ JOINs

  • Indicator: Query composes nested data from multiple tables on every read
  • Action: Pre-compose nested data into a tv_* JSONB column
  • Benefit: Single indexed lookup vs. multiple JOINs

✅ High read volume (>100 requests/sec) to the same data structure

  • Indicator: Database CPU high during peak traffic
  • Action: Cache the composition in a tv_* table
  • Benefit: Query cost moves from compute-heavy to storage-read

✅ Real-time GraphQL subscriptions require fast updates

  • Indicator: Subscription latency varies with nesting depth
  • Action: Trigger-based tv_* refresh ensures consistent latency
  • Benefit: Subscription updates in <100ms

Don't Migrate when

❌ Query already fast (<500ms)

  • Keep the logical view unless write overhead forces migration

❌ Storage is severely constrained

  • Keep the logical view; optimize the underlying query instead

❌ Write volume is unpredictable

  • Refresh triggers may become overhead; use a scheduled batch refresh instead

Migration Path Example

Complex User Profile (v_* → tv_*)

Current State:

-- v_user_full: logical view with real-time composition
-- Query time: 3-5 seconds
SELECT * FROM v_user_full WHERE id = $1;

Problem: Users reported slow profile loading on high-traffic pages.

Decision: Migrate to a tv_* table-backed projection for pre-composed data.

Implementation:

-- Step 1: Create the projection table holding pre-composed JSONB
CREATE TABLE tv_user_profile AS
SELECT id, data FROM v_user_full;

-- Step 2: Keep it fresh with a trigger on the source write table
CREATE TRIGGER trg_refresh_tv_user_profile
    AFTER INSERT OR UPDATE OR DELETE ON tb_user
    FOR EACH ROW EXECUTE FUNCTION fn_refresh_tv_user_profile();
# Step 3: Point the GraphQL type at the projection view (runtime binding)
import fraiseql
from fraiseql.types import ID

@fraiseql.type(sql_source="tv_user_profile", jsonb_column="data")
class User:
    id: ID
    name: str
    posts: list["Post"]

Before/After:

  • Before: 3-5 second page load + database spike during peak traffic
  • After: 100-200ms page load, consistent performance

Client API

Clients never choose a view. They issue the same GraphQL query regardless of whether the type is backed by a v_* or a tv_* view — the server-side binding determines which one runs:

query {
  user(id: "550e8400-e29b-41d4-a716-446655440000") {
    id
    name
    posts {
      id
      title
      comments {
        id
        text
      }
    }
  }
}

To move from a logical view to a projection view, change only sql_source:

  • Simple queries: @fraiseql.type(sql_source="v_user", jsonb_column="data")
  • Complex queries: @fraiseql.type(sql_source="tv_user_profile", jsonb_column="data")

Decision Checklist

Before creating a new view, answer these questions:

  • Is the query for a single entity? → Use v_*
  • Does the query require 3+ JOINs? → Consider tv_*
  • Are query times > 1 second? → Use tv_*
  • Is read volume > 100 reqs/sec to this view? → Use tv_*
  • Can you accept 100-300ms latency? → Use v_*
  • Do you need real-time subscriptions? → Use tv_* with refresh triggers

Recommendation: Default to v_*. Migrate to tv_* only when production metrics require it.

Performance Testing

Measure both views with EXPLAIN (ANALYZE, BUFFERS) and compare the reported Execution Time:

-- Measure the logical view
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM v_user_full WHERE id = $1;

-- Measure the table-backed projection (should be 10-50x faster)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM tv_user_profile WHERE id = $1;

See Also