PostgreSQL generated columns for faster admin filters
Learn how PostgreSQL generated columns can speed up admin screen filters and sorting while keeping SQL readable, with practical examples and quick checks.

Why admin screens get slow and messy fast
Admin screens usually start simple: a table, a couple of filters, maybe a sort by “newest first.” Then real work begins. Support wants search that finds customers by name, email, and phone. Sales wants sorting by “last activity.” Finance wants “overdue balance.” Each request adds conditions, joins, and extra calculations.
Most admin lists get slow for the same reason: every click changes the query. Filtering and sorting can push the database into scanning lots of rows, especially when the query has to compute a value for each row before it can decide what matches.
A common turning point is when WHERE and ORDER BY fill up with expressions. Instead of filtering on a plain column, you filter on lower(email), date_trunc('day', last_seen_at), or a CASE statement that maps multiple statuses into one “bucket.” Those expressions aren’t just slower. They make the SQL harder to read, harder to index, and easier to get wrong.
Messy admin SQL usually comes from a few repeating patterns:
- One “search” input that checks several fields with different rules
- Sorting by a derived value (full name, priority score, “last meaningful event”)
- Business rules copied across screens (active vs inactive, paid vs overdue)
- Little “helper” tweaks (
trim,lower,coalesce) sprinkled everywhere - The same computed value used in the list, filters, and sorting
Teams often try to hide this in the app layer: dynamic query builders, conditional joins, or precomputing values in code. That can work, but it splits logic between the UI and the database, which makes debugging slow queries painful.
The goal is straightforward: fast queries that stay readable. When a computed value shows up again and again across admin screens, PostgreSQL generated columns can keep the rule in one place while still letting the database optimize it.
Generated columns in plain English
A generated column is a regular table column whose value is calculated from other columns. You don’t write the value yourself. PostgreSQL fills it in using an expression you define.
In PostgreSQL, generated columns are stored. PostgreSQL computes the value when a row is inserted or updated, then saves it on disk like any other column. That’s usually what you want for admin screens: fast reads, and the ability to index the computed value.
This differs from doing the same calculation inside every query. If you keep writing WHERE lower(email) = lower($1) or sorting by last_name || ', ' || first_name, you pay the cost repeatedly and your SQL gets noisy. A generated column moves that repeated calculation into the table definition. Your queries become simpler, and the result is consistent everywhere.
When source data changes, PostgreSQL updates the generated value automatically for that row. Your app doesn’t have to remember to keep it in sync.
A useful mental model:
- Define the formula once.
- PostgreSQL computes it on writes.
- Queries read it like a normal column.
- Because it’s stored, you can index it.
If you later change the formula, you’ll need a schema change. Plan it like any migration, because existing rows will be updated to match the new expression.
Good uses for computed fields in filters and sorting
Generated columns shine when the value is always derived from other columns and you filter or sort on it often. They’re less helpful for one-off reports.
Search-friendly fields people actually use
Admin search is rarely “pure” search. People expect the box to handle messy text, inconsistent casing, and extra spaces. If you store a generated “search key” that’s already normalized, your WHERE clause stays readable and behaves the same across screens.
Good candidates include a combined full name, lowercased and trimmed text for case-insensitive search, a cleaned version that collapses whitespace, or a status label derived from multiple fields.
Example: instead of repeating lower(trim(first_name || ' ' || last_name)) in every query, generate full_name_key once and filter on it.
Sorting keys that match how humans sort
Sorting is where computed fields often pay off fastest, because sorting can force PostgreSQL to evaluate expressions for many rows.
Common sorting keys include a numeric rank (plan tier mapped to 1, 2, 3), a single “latest activity” timestamp (like the max of two timestamps), or a padded code that sorts correctly as text.
When the sort key is a plain indexed column, ORDER BY becomes much cheaper.
Derived flags for quick filters
Admin users love checkboxes like “Overdue” or “High value.” These work well as generated columns when the logic is stable and based only on row data.
For example, if a customer list needs “Has unread messages” and “Is overdue,” a generated has_unread boolean (from unread_count > 0) and is_overdue (from due_date < now() and paid_at is null) makes UI filters map to simple conditions.
Choosing between generated columns, indexes, and other options
Admin screens need three things: fast filtering, fast sorting, and SQL you can still read months later. The real decision is where the computation should live: in the table, in an index, in a view, or in app code.
Generated columns are a good fit when you want the value to behave like a real column: easy to reference, visible in selects, and hard to forget when adding new filters. They also pair naturally with normal indexes.
Expression indexes can be quicker to add because you don’t change the table definition. If you mostly care about speed and don’t mind uglier SQL, an expression index is often enough. The downside is readability, and you’re relying on the planner matching your expression exactly.
Views help when you want a shared “shape” of data, especially if your admin list joins many tables. But complex views can hide expensive work and add a second place to debug.
Triggers can keep a normal column in sync, but they add moving parts. They can make bulk updates slower and can be easy to overlook during troubleshooting.
Sometimes the best option is a plain column filled by the app. If users can edit it, or if the formula changes often based on business decisions (not just row data), keeping it explicit is clearer.
A quick way to choose:
- Want readable queries and a stable formula based only on row data? Use a generated column.
- Want speed for one specific filter and don’t mind noisy SQL? Use an expression index.
- Need a joined, report-like shape reused in many places? Consider a view.
- Need cross-table logic or side effects? Prefer app logic first, triggers last.
Step by step: add a generated column and use it in a query
Start with one slow admin list query you can feel in the UI. Write down the filters and sort the screen uses most. Improve that single query first.
Pick a computed field that removes repeated work, and name it clearly in snake_case so others can guess what it holds without rereading the expression.
1) Add the generated column (STORED)
ALTER TABLE customers
ADD COLUMN full_name_key text
GENERATED ALWAYS AS (
lower(concat_ws(' ', last_name, first_name))
) STORED;
Validate on real rows before adding indexes:
SELECT id, first_name, last_name, full_name_key
FROM customers
ORDER BY id DESC
LIMIT 5;
If the output is wrong, fix the expression now. STORED means PostgreSQL will keep it updated for every insert and update.
2) Add the index that matches your admin screen
If your admin screen filters by status and sorts by name, index that pattern:
CREATE INDEX customers_status_full_name_key_idx
ON customers (status, full_name_key);
3) Update the admin query to use the new column
Before, you might have had a messy ORDER BY. After, it’s obvious:
SELECT id, status, first_name, last_name
FROM customers
WHERE status = 'active'
ORDER BY full_name_key ASC
LIMIT 50 OFFSET 0;
Use generated columns for the parts people filter and sort on every day, not for rare screens.
Indexing patterns that match real admin screens
Admin screens repeat a few behaviors: filter by a handful of fields, sort by one column, and paginate. The best setup is rarely “index everything.” It’s “index the exact shape of the most common queries.”
A practical rule: put the most common filter columns first, and the most common sort column last. If you’re multi-tenant, workspace_id (or similar) often comes first: (workspace_id, status, created_at).
Text search is its own problem. Many search boxes end up as ILIKE '%term%', which is hard to speed up with basic btree indexes. A helpful pattern is to search a normalized helper column instead of raw text (lowercase, trimmed, maybe concatenated). If your UI can use prefix search (term%), a btree index on that normalized column can help. If it must be contains search (%term%), consider tightening the UI behavior for large tables (for example, “email starts with”), or limit search to a smaller subset.
Also check selectivity before adding indexes. If 95% of rows share the same value (like status = 'active'), indexing that column alone won’t help much. Pair it with a more selective column, or use a partial index for the minority case.
Realistic example: a customer admin list that stays fast
Picture a typical customer admin page: a search box, a few filters (inactive, balance range), and a sortable “Last seen” column. Over time it turns into hard-to-read SQL: LOWER(), TRIM(), COALESCE(), date math, and CASE blocks repeated across screens.
One way to keep it fast and readable is to push those repeated expressions into generated columns.
Table and generated columns
Assume a customers table with name, email, last_seen, and balance. Add three computed fields:
search_key: a normalized text blob for simple searchesis_inactive: a boolean you can filter on without repeating date logicbalance_bucket: a label for quick segmentation
ALTER TABLE customers
ADD COLUMN search_key text
GENERATED ALWAYS AS (
lower(trim(coalesce(name, ''))) || ' ' || lower(trim(coalesce(email, '')))
) STORED,
ADD COLUMN is_inactive boolean
GENERATED ALWAYS AS (
last_seen IS NULL OR last_seen < (now() - interval '90 days')
) STORED,
ADD COLUMN balance_bucket text
GENERATED ALWAYS AS (
CASE
WHEN balance < 0 THEN 'negative'
WHEN balance < 100 THEN '0-99'
WHEN balance < 500 THEN '100-499'
ELSE '500+'
END
) STORED;
Now the admin query reads like the UI.
Readable filter + sorting
“Inactive customers, newest activity first” becomes:
SELECT id, name, email, last_seen, balance
FROM customers
WHERE is_inactive = true
ORDER BY last_seen DESC NULLS LAST
LIMIT 50;
And a basic search becomes:
SELECT id, name, email, last_seen, balance
FROM customers
WHERE search_key LIKE '%' || lower(trim($1)) || '%'
ORDER BY last_seen DESC NULLS LAST
LIMIT 50;
The real win is consistency. The same fields power multiple screens without rewriting logic:
- The customer list search box uses
search_key - The “Inactive customers” tab uses
is_inactive - Balance filter chips use
balance_bucket
Common mistakes and traps
Generated columns can look like a simple win: put the math in the table and keep your queries clean. They only help when they match how the screen filters and sorts, and when you add the right index.
The most common mistakes:
- Assuming it speeds things up without indexing. A computed value still needs an index for fast filtering or sorting at scale.
- Packing too much logic into one field. If a generated column becomes a mini program, people stop trusting it. Keep it short and name it clearly.
- Using non-immutable functions. PostgreSQL requires the expression for a stored generated column to be immutable. Things like
now()andrandom()break expectations and often aren’t allowed. - Ignoring write cost. Inserts and updates must maintain the computed value. Faster reads aren’t worth it if imports and integrations slow down too much.
- Creating near-duplicates. Standardize one or two patterns (like a single normalized key) instead of accumulating five similar columns.
If your admin list does contains searches (like ILIKE '%ann%'), a generated column alone won’t save it. You may need a different search approach. But for the everyday “filter and sort” workhorse queries, generated columns plus the right index usually make performance much more predictable.
Quick checklist before you ship
Before pushing changes to an admin list, verify that the computed value, query, and index line up.
- The formula is stable and easy to explain in one sentence.
- Your query actually uses the generated column in
WHEREand/orORDER BY. - The index matches real usage, not a one-off test.
- You compared results with the old logic on edge cases (NULLs, empty strings, odd spacing, mixed case).
- You tested write performance if the table is busy (imports, background updates, integrations).
Next steps: apply this to your admin screens
Pick a small, high-impact starting point: the 2-3 admin screens people open all day (orders, customers, tickets). Note what feels slow (a date range filter, sorting by “last activity,” searching by combined name, filtering by a status label). Then standardize a short set of computed fields you can reuse across screens.
A rollout plan that’s easy to measure and easy to undo:
- Add the generated column(s) with clear names.
- Run old and new side by side briefly if you’re replacing existing logic.
- Add the index that matches the main filter or sort.
- Switch the screen’s query to use the new column.
- Measure before and after (query time and rows scanned), then remove the old workaround.
If you’re building internal admin tools in AppMaster (appmaster.io), these computed fields fit nicely into a shared data model: the database carries the rule, and your UI filters can point at a straightforward field name instead of repeating expressions across screens.
FAQ
Generated columns help when you keep repeating the same expression in WHERE or ORDER BY, like normalizing names, mapping statuses, or building a sorting key. They’re especially useful for admin lists that are opened all day and need predictable filtering and sorting.
A stored generated column is computed on insert or update and saved like a normal column, so reads can be fast and indexable. An expression index stores the result in the index without adding a new table column, but your queries still need to use the exact expression for the planner to match it.
No, not by itself. A generated column mainly makes the query simpler and makes indexing a computed value straightforward, but you still need an index that matches your common filters and sorts if you want real speedups at scale.
Usually it’s a field you filter or sort on constantly: a normalized search key, a “full name” sort key, a derived boolean like is_overdue, or a ranking number that matches how people expect results to sort. Pick one value that removes repeated work from many queries, not a one-off calculation.
Start with the most common filter columns, then put the main sort key last, like (workspace_id, status, full_name_key) if that matches the screen. This lets PostgreSQL filter quickly and then return rows already ordered without extra work.
Not very. A generated column can normalize text so behavior is consistent, but ILIKE '%term%' still tends to be slow with basic btree indexes on large tables. If performance matters, prefer prefix-style search where you can, reduce the searched dataset with other filters, or adjust the UI behavior for big tables.
Stored generated columns have to be based on immutable expressions, so functions like now() typically aren’t allowed and would also be conceptually wrong because the value would go stale. For time-based flags like “inactive for 90 days,” consider a normal column maintained by a job, or compute it at query time if it’s not heavily used.
Yes, but plan it like a real migration. Changing the expression means updating the schema and recomputing values for existing rows, which can take time and add write load, so do it in a controlled deployment window if the table is large.
Yes. The database has to compute and store the value on every insert and update, so heavy write workloads (imports, sync jobs) can slow down if you add too many generated fields or complex expressions. Keep expressions short, add only what you use, and measure write performance on busy tables.
Add a generated column, validate a few real rows, then add the index that matches the screen’s main filter and sort. Update the admin query to use the new column directly, and compare query time and rows scanned before and after to confirm the change helped.


