Your Database Is Sending Way Too Much: A Practical Guide to Leaner Query Results
Here's an uncomfortable truth about modern application development: most of us have been writing database queries the same way since we learned to code, and most of those habits are expensive.
SELECT *. The asterisk. The 'give me everything' operator. It's the query equivalent of asking your waiter to bring the entire menu instead of ordering a meal. And while it's easy to blame junior developers, the real culprit is often the ORM sitting between your code and your database, quietly issuing bloated queries on your behalf while you write what looks like clean, expressive application logic.
The result? Applications that pull megabytes of data from a database to render a page that only displays three fields. Memory buffers that swell unnecessarily. Database I/O that costs real money on managed cloud services. And a performance ceiling that gets lower with every new feature you ship.
This isn't a theoretical problem. Teams that audit their actual query result sizes consistently find they're fetching 40–70% more data than their application logic ever touches.
The ORM Convenience Tax
ORMs like ActiveRecord, Hibernate, SQLAlchemy, and Prisma are genuinely great tools. They reduce boilerplate, make relationships easy to navigate, and let developers work at a higher level of abstraction. But that abstraction has a default setting, and the default is almost always "fetch everything."
When you write User.find(id) in Rails or userRepository.findById(id) in Spring, the ORM typically issues a SELECT * equivalent against your users table. If that table has 40 columns—and production tables tend to accumulate columns over time—you're loading all 40 into memory for every single lookup, even if your code only touches first_name and email.
At low scale, this is invisible. At 50,000 requests per hour, you're burning memory and database I/O on data you're throwing away immediately. A fintech startup that recently went through this audit found that their user lookup queries were pulling an average of 3.2KB per row when their application logic needed roughly 400 bytes. Switching to explicit column selection in their most frequent queries dropped database I/O by 58% on those paths and meaningfully reduced their RDS costs.
What 'Lazy Loading' Actually Means in Practice
Lazy loading sounds like a good thing—load data only when you need it, right? In practice, it's a double-edged sword that requires deliberate configuration to work in your favor.
The classic trap is the N+1 query problem (if you've read our piece on hunting down N+1 issues, you know this territory). But even setting N+1 aside, lazy loading can mean your application silently issues follow-up queries for related objects you didn't explicitly request—and those related objects come back fully hydrated, columns and all.
The fix isn't to abandon lazy loading or to always eager-load everything. It's to be intentional. When you know you'll need a relationship, eager-load it with explicit column selection. When you genuinely won't need it, make sure your ORM isn't sneaking a query in anyway. Most ORMs support select() or only() modifiers that let you constrain what comes back from both the primary query and any eager-loaded associations.
Columnar Thinking for Row-Based Databases
Here's a mental model shift that pays dividends: start thinking about your data access patterns in terms of columns, not rows.
Most relational databases store data in rows, but your application accesses it in workflows. A user list page needs id, name, and created_at. A billing summary needs id, plan_tier, and billing_email. A permissions check needs id and role. These are three completely different column sets from the same table.
Mapping your application's workflows to the minimum column set each one needs is the core practice here. It takes maybe an afternoon to do this for your five most-trafficked code paths, and the payoff in reduced data transfer and memory usage is typically immediate and measurable.
Some teams go further and create database views for their most common access patterns—essentially pre-defined "narrow" versions of wide tables. This adds a layer of indirection but means application code can query a view and get exactly what it needs without developers having to remember to specify columns every time.
The Case Against Fetching for Display
One of the sneakiest over-fetching patterns is what you might call "fetch for display"—loading a full object from the database in order to render a summary or a list item.
Imagine an admin dashboard that shows a table of recent orders: order number, customer name, total, and status. Four fields. But the query behind that page loads the full order object, including line items, shipping address, billing address, internal fulfillment flags, and audit timestamps. All of that gets deserialized into application objects, mapped through a view layer, and then mostly discarded before a single byte hits the browser.
The fix is a dedicated query for the list view—one that explicitly selects the four display fields and nothing else. This feels like extra work if you're used to reusing model objects everywhere, but the performance difference on high-traffic list endpoints is dramatic. One e-commerce platform reduced the memory footprint of their orders list endpoint by 71% this way, which had a direct impact on their container sizing and hosting costs.
A Framework for Deciding What to Fetch
Not every query needs to be surgically optimized. Here's a quick decision framework for prioritizing where to focus:
High frequency + wide table = immediate candidate. Any query that runs thousands of times per hour against a table with more than 15 columns deserves an explicit column audit.
List views vs. detail views. List endpoints almost always over-fetch. Detail endpoints are more likely to legitimately need a full record. Treat them differently.
Aggregation vs. retrieval. If you're fetching records to compute a count, sum, or average, do it in the database. SELECT COUNT(*) beats loading 10,000 rows into application memory and calling .length.
Write paths vs. read paths. Write operations often legitimately need full records for validation and update logic. Read operations rarely do. Don't apply the same query pattern to both.
The broader mindset shift is moving from "fetch everything and filter in the application" to "filter in the database and fetch only what's needed." Databases are extraordinarily good at filtering. Application memory is not free. Lean into the tools that are actually built for the job.