Skip to content

Projection Tables

In FraiseQL’s CQRS pattern, tb_* tables handle writes and v_* views handle reads. Projection tables sit between the two: they are regular tables (prefixed tv_* by convention) that store pre-computed aggregates, updated by database triggers or observers when the write side changes, and joined into your v_* views at read time.

This guide adds tv_post_stats to the blog API from Your First API. By the end, every post query will return commentCount, viewCount, and lastActivity without a single COUNT(*) subquery at read time.

The v_post view from the getting-started guide aggregates comments with jsonb_agg. That works for fetching comment content, but as your blog grows, every request to list posts runs a COUNT across the entire tb_comment table. When a post has 50,000 comments and you are listing 20 posts, you pay for that aggregation on every read.

Projection tables solve this by moving the aggregation cost to write time. Comment inserted? Increment the counter. Comment deleted? Decrement it. The read path becomes a simple JOIN.

Projection tables live alongside your write tables in db/schema/01_write/. The tv_* prefix distinguishes them from base tables (tb_*) and views (v_*).

db/schema/01_write/tv_post_stats.sql
CREATE TABLE tv_post_stats (
fk_post BIGINT PRIMARY KEY
REFERENCES tb_post(pk_post)
ON DELETE CASCADE,
comment_count INT NOT NULL DEFAULT 0,
view_count BIGINT NOT NULL DEFAULT 0,
last_activity TIMESTAMPTZ
);

ON DELETE CASCADE means that when a post is deleted, its stats row is automatically removed — no orphan cleanup required.

When you add tv_post_stats to an existing blog, you need a row for every post that already exists. This seed script runs once, typically as part of a migration.

db/schema/03_seed/seed_tv_post_stats.sql
INSERT INTO tv_post_stats (fk_post, comment_count, last_activity)
SELECT
p.pk_post,
COUNT(c.pk_comment),
MAX(c.created_at)
FROM tb_post p
LEFT JOIN tb_comment c ON c.fk_post = p.pk_post
GROUP BY p.pk_post
ON CONFLICT (fk_post) DO NOTHING;

For a brand-new blog with no data, you can skip this step — rows are created on first comment insert by the triggers below.

Triggers maintain tv_post_stats automatically. Every time a comment is inserted or deleted, the corresponding post’s counters are updated atomically within the same transaction.

db/schema/01_write/triggers_tv_post_stats.sql
-- Trigger function shared by insert and delete triggers
CREATE OR REPLACE FUNCTION fn_update_post_stats()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO tv_post_stats (fk_post, comment_count, last_activity)
VALUES (NEW.fk_post, 1, NEW.created_at)
ON CONFLICT (fk_post) DO UPDATE
SET comment_count = tv_post_stats.comment_count + 1,
last_activity = GREATEST(
tv_post_stats.last_activity,
NEW.created_at
);
ELSIF TG_OP = 'DELETE' THEN
UPDATE tv_post_stats
SET
comment_count = GREATEST(0, comment_count - 1),
last_activity = (
SELECT MAX(created_at)
FROM tb_comment
WHERE fk_post = OLD.fk_post
AND pk_comment <> OLD.pk_comment
)
WHERE fk_post = OLD.fk_post;
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_comment_insert_stats
AFTER INSERT ON tb_comment
FOR EACH ROW EXECUTE FUNCTION fn_update_post_stats();
CREATE TRIGGER trg_comment_delete_stats
AFTER DELETE ON tb_comment
FOR EACH ROW EXECUTE FUNCTION fn_update_post_stats();

The insert trigger uses an upsert so that the first comment on a post creates the stats row automatically, even if seed_tv_post_stats.sql was not run.

Update v_post to JOIN tv_post_stats and include the three new fields in the JSON object. Replace the existing v_post file entirely.

db/schema/02_read/v_post.sql
CREATE VIEW v_post AS
SELECT
p.id,
jsonb_build_object(
/*keys*/
'id', p.id::text,
/*scalar fields*/
'title', p.title,
'slug', p.slug,
'content', p.content,
'is_published', p.is_published,
'created_at', p.created_at,
'updated_at', p.updated_at,
/*projection fields*/
'comment_count', COALESCE(s.comment_count, 0),
'view_count', COALESCE(s.view_count, 0),
'last_activity', s.last_activity,
/*relationships*/
'author', vu.data,
'comments', COALESCE(
jsonb_agg(vc.data) FILTER (WHERE vc.id IS NOT NULL),
'[]'::jsonb
)
) AS data
FROM tb_post p
JOIN tb_user u ON u.pk_user = p.fk_user
JOIN v_user vu ON vu.id = u.id
LEFT JOIN tv_post_stats s ON s.fk_post = p.pk_post
LEFT JOIN v_comment vc ON vc.fk_post = p.pk_post
GROUP BY p.pk_post, vu.data, s.comment_count, s.view_count, s.last_activity;

The projection join is a LEFT JOIN — posts without a stats row (no comments yet) still appear, with COALESCE returning 0 for the counters and NULL for last_activity.

Add the three new fields to your Post type. FraiseQL maps JSON object keys from the view directly to type fields.

schema.py
from datetime import datetime
@fraiseql.type
class Post:
"""A blog post."""
id: ID
title: str
slug: Slug
content: str
is_published: bool
created_at: DateTime
updated_at: DateTime
author: User
comments: list['Comment']
# Projection fields from tv_post_stats
comment_count: int
view_count: int
last_activity: datetime | None

The field names follow each language’s conventions. FraiseQL maps comment_count (snake_case in JSON) to commentCount (camelCase in GraphQL and TypeScript/Go) automatically.

Unlike comment count, view count is not driven by a structural database event — it is driven by user behaviour. You have two options.

Add a recordView mutation that calls a database function to increment view_count. This is the simplest approach and gives you full control over when a view is counted.

schema.py
@fraiseql.input
class RecordViewInput:
post_id: ID
@fraiseql.mutation(operation="custom", sql_function="fn_record_post_view")
def record_view(input: RecordViewInput) -> bool:
"""Increment view count for a post."""
pass

Then write the backing function:

db/schema/01_write/fn_record_post_view.sql
CREATE OR REPLACE FUNCTION fn_record_post_view(p_post_id UUID)
RETURNS BOOLEAN LANGUAGE plpgsql AS $$
DECLARE
v_pk BIGINT;
BEGIN
SELECT pk_post INTO v_pk
FROM tb_post
WHERE id = p_post_id;
IF v_pk IS NULL THEN
RETURN FALSE;
END IF;
INSERT INTO tv_post_stats (fk_post, view_count)
VALUES (v_pk, 1)
ON CONFLICT (fk_post) DO UPDATE
SET view_count = tv_post_stats.view_count + 1;
RETURN TRUE;
END;
$$;

Your client fires this mutation immediately after displaying a post:

mutation {
recordView(input: { postId: "pst_01HZ4M" })
}
Section titled “Option B: Observer (Recommended for High Traffic)”

For APIs where every post query should trigger a view increment, use an observer that reacts to v_post reads and calls fn_record_post_view asynchronously. This keeps the read path fast by decoupling the increment from the query response.

See the Observers guide for the full setup.

Rebuild the database to apply the new table, triggers, and view:

Terminal window
confiture build --env local
fraiseql compile
fraiseql run

Then run a query that uses the projected fields:

query {
posts(isPublished: true, orderBy: LAST_ACTIVITY_DESC) {
title
commentCount
viewCount
lastActivity
}
}

Response:

{
"data": {
"posts": [
{
"title": "Scaling PostgreSQL with FraiseQL",
"commentCount": 42,
"viewCount": 1893,
"lastActivity": "2026-02-20T14:33:07Z"
},
{
"title": "Hello World",
"commentCount": 3,
"viewCount": 210,
"lastActivity": "2026-02-18T09:11:52Z"
}
]
}
}

Notice that orderBy: LAST_ACTIVITY_DESC works without any application-level sorting — the value is stored in tv_post_stats, JOINed into v_post, and the FraiseQL query layer sorts on it directly.

Projection tables are the right tool when:

  • Aggregates appear on hot read paths. If COUNT(*), SUM(), or MAX() appear in a view that is queried frequently, pre-compute the value in a tv_* table and update it at write time.
  • You need to sort or filter on a derived value. ORDER BY comment_count DESC requires the value to be a real column. A subquery in ORDER BY forces a full scan; a projection column uses an index.
  • A query joins more than two tables. Views that chain three or more joins benefit from flattening some of those relationships into a projection row.
  • Aggregate staleness is acceptable. Triggers are synchronous — your projection is always consistent. If you use observers for async updates, accept that counts may lag by milliseconds.

Avoid projection tables for:

  • Infrequently queried aggregates. If a report runs once a day, a COUNT(*) subquery is fine.
  • Rapidly changing data with low read volume. The trigger overhead exceeds the read savings.
  • Data that must be perfectly consistent with complex business rules. Triggers are powerful but hard to test in isolation; complex logic belongs in application-layer observers instead.
  • Observers — Trigger side effects that update projections asynchronously
  • Custom Queries — Filter and sort using projected columns with FraiseQL’s query DSL
  • Threaded Comments — Another use case for projections, tracking comment depth and reply counts