Why Every SaaS Dev Hates the Column-Filter Ticket
A 'just add a column' ticket looks like 30 minutes of work. The real cost is a sprint, every quarter, forever. Here's the math, and the way out.
The ticket lands in the queue on a Tuesday afternoon.
TICKET-1847: Customer asked if we can add a "Last Login" column to the Users table. They want it sortable. Default sort by most recent.
Twenty minutes max, right? An indexed column, a backend field on the API response, one new entry in the table config, ship it. I have estimated this ticket maybe a hundred times. It is never twenty minutes.
I want to walk through why, because once you see the real cost of this ticket, you understand why your roadmap keeps slipping. And once you see that, you start looking for the structural fix.
The honest cost of one column
Let us start with the actual scope. The data already exists. We have a last_login_at timestamp on the users table. Cool. So the work is just plumbing.
Here is the diff, roughly:
// 1. Backend: add the field to the API response
export const userSerializer = (user: User) => ({
id: user.id,
email: user.email,
role: user.role,
created_at: user.created_at,
last_login_at: user.last_login_at, // new
})
// 2. Frontend: add the column definition
export const userColumns: ColumnDef<User>[] = [
{ key: 'email', header: 'Email', sortable: true },
{ key: 'role', header: 'Role', sortable: false },
{ key: 'created_at', header: 'Created', sortable: true, format: 'date' },
{ key: 'last_login_at', header: 'Last Login', sortable: true, format: 'relative-time' }, // new
]
// 3. Backend: extend the sort allowlist
const ALLOWED_SORT_FIELDS = ['email', 'created_at', 'last_login_at'] // updated
Three small changes. Fifteen lines including imports. If I were the only one looking at this code, I would ship it in twenty minutes.
I am not the only one looking at this code. Nobody is. Here is what actually happens.
The PR. I open it. The CI pipeline runs the existing test suite (8 minutes), then the visual regression tests on the table component because I changed it (another 6 minutes), then the integration tests (4 minutes). The bundle size check fails because the relative-time formatter is heavier than the date formatter we already had. I fix it by lazy-loading the formatter. CI runs again. Another 18 minutes. We are now past lunch.
The review. Someone has to read this. They will have one of two reactions. Reaction A: "LGTM," nothing to add. Reaction B: "Why are we adding a sortable index on last_login_at? Have we benchmarked this against the existing query plan? Also, does our analytics consent flow allow us to display login data, or is that EU-restricted?" Reaction B is the right question and it eats the rest of the afternoon, plus a Slack thread with the data team. Either way, the PR sits in review for a day, probably two.
QA. Our QA engineer pulls down the branch, opens the table, checks the column renders, checks the sort works, checks the empty state for users who have never logged in. She finds that the default sort by "Last Login (most recent first)" pushes never-logged-in users to the bottom, which is fine, but it also makes the test fixture tenant look completely empty because half the seed users have a NULL last_login_at. So the empty state in our own demo environment looks broken. She opens a sub-ticket: TICKET-1851, "Update demo seed data to include last_login_at values." That ticket gets thrown back at engineering. Add another half day.
The docs. The help center article for the Users table needs updating. Now there is a screenshot to retake. The customer success team's training module needs a refresh. The release notes need a line item. None of this is hard. All of it is friction.
The ripple. The mobile app team finds out we changed the user serializer in API v3 and asks us to bump it to v4 because they have a release going out next week and they cannot absorb an unannounced field. Now we are versioning an endpoint over one column.
By the time the column is in production, we have spent about three engineer-days on it. Not twenty minutes. Three days.
The ticket comes back
Two weeks later, a different customer opens TICKET-2103.
Hey, love the new Last Login column. Question, can you make it default to weekly active users instead? We don't really care about exact login times, we care about who is engaged.
Reasonable. Fair. Let us scope it. "Weekly active" is a derived metric. We need to compute distinct logins in the last 7 days per user, which means a new query, probably a materialized view if we want it to be fast at scale. Two days, maybe three.
Then TICKET-2204:
Can we get a column for "Days since last login"? Last Login as a timestamp is great but I want to scan and immediately see who is dormant.
So now we have three columns serving variants of the same question. We did not plan this. We did not architect a system for "engagement views." We are pattern-matching to individual customer requests one at a time, and each one looks reasonable, and each one costs an engineer-day or two.
This is the customization backlog. It is not one big project. It is a hundred small ones, each of which feels like the right thing to do, and together they own a quarter of your sprint capacity.
I have done the math on this with engineering teams I have advised. The number that keeps coming back is 25 to 35 percent of capacity going to "small" customization tickets. That is one full sprint per quarter that ships zero new product. Every quarter. Forever.
The reason this happens
The customization ticket is uniquely awful because it does not feel like a problem. Each individual ticket is reasonable. Each one is small. Each one makes a customer happy. Saying no to any single one feels petty.
But the cost is invisible. Nobody is tracking "engineering hours spent on customization requests" in your dashboard. Nobody is showing you the chart of features that did not ship because you were busy plumbing columns. The cost shows up later, when you look at your roadmap and realize you have been working for eight months and the headline feature is still in design.
What makes it structural is that there is no mechanism for a customer to make this change without a developer. They cannot. We did not build it that way. So the only path from "I want a column" to "the column exists" runs through a PR, a review, a QA pass, and a deploy.
Three engineer-days. Per column. Forever.
What if customers could just do this themselves
Here is the thing that bothered me. The customer asking for "Last Login" did not need me to build a feature. They needed to add a column to a table. If they had been editing a spreadsheet in Google Sheets, this would have been a right-click and "Insert column to the right." Two seconds. No PR. No QA.
The reason it was three days for us is that a SaaS dashboard table is not a spreadsheet. It is a controlled view, with a serializer on one end and a render layer on the other and an authorization model in the middle. We built the controls because we needed them. Now the controls are eating us.
The fix is not "give customers a spreadsheet." The fix is to declare, in code, which axes of the dashboard are safe for customers to shape, and let them shape those axes themselves. The developer keeps control of what is shapeable. The customer gets control of how their view looks within that shape space.
This is what we built ShapeKit for. The Last Login ticket, in a ShapeKit dashboard, is not a ticket. It is a Crafter (the developer) deciding once that the column is shapeable, and a Shaper (the customer) clicking a button.
// The Crafter declares the shape space, once.
const usersSkill = ShapeKit.define({
name: 'Users',
columns: {
email: { fixed: true },
role: { fixed: true },
created_at: { shapeable: true, default: true },
last_login_at: { shapeable: true, default: false },
days_since_last_login: { shapeable: true, default: false },
weekly_active: { shapeable: true, default: false },
},
maxVisibleColumns: 6,
sortable: ['email', 'created_at', 'last_login_at', 'days_since_last_login'],
})
That is the entire developer side. The customer who wants Last Login opens the dashboard, clicks the Shape button, and adds the column. The customer who wants Weekly Active does the same. The customer who wants Days Since Last Login does the same. None of them open a ticket. None of them wait three days. The Crafter wrote the rules once. The Shapers customize forever.

The shift is not technical magic. It is a structural change in who is allowed to make the change. The developer's job becomes "decide what is shapeable." The customer's job becomes "shape it." The PR queue stops growing.
The honest tradeoff
ShapeKit is not the right answer to every customization request. Some things are not safe to expose. Auth flows are not shapeable. Billing is not shapeable. Data integrity rules are not shapeable. The Crafter has to make those calls.
But the long tail of "can you add a column," "can I sort by this," "can I filter on that" is exactly the kind of work that should never have been a ticket in the first place. We were the only thing standing between the customer and the change. Removing ourselves from that loop is the highest-leverage product decision we have made in a year.
I write this from a place of having lived the ticket queue for ten years before I started ShapeKit. I am not telling you "stop building features." I am telling you that the column-filter ticket is the wrong shape of problem, and the answer is not to get faster at responding to it. The answer is to make it not be a ticket.
If you are the engineer who is about to scope another "small" customization request, do the math. Add up the columns you have shipped in the last quarter. Multiply by three days. That is how much real product time you traded for plumbing.
You can get that time back.
ShapeKit launches the constrained shaping model for any team that wants it. Free tier (Forge) is live. See pricing or see how it works.
Next post: The 80/20 of SaaS customization, what to build vs. what to let users shape.