You're Fetching More Data Than You Need and It's Slowing You Down

by Eric Hanson, Backend Developer at Clean Systems Consulting

The invisible tax on every request

Your API endpoint responds in 400ms. You profile it and find the database query takes 80ms. The rest of the time is spent serializing a 2MB payload that the frontend uses to display a 12-row table with three columns. The query itself is fast. The problem is that you're fetching the entire products table — all 40 columns including description, raw_import_data JSONB, and legacy_notes TEXT — and discarding 37 of those columns before the response is sent.

Over-fetching is the dominant form of database waste in CRUD-heavy applications. It's not a query plan problem. It's a "what did you ask for" problem. And it compounds: more data means more memory pressure, more serialization time, more network bytes, and larger caches that evict faster.

The SELECT * habit

SELECT * is fine for exploratory queries in a database console. It is not fine in application code.

-- What the ORM generates by default
SELECT * FROM products WHERE category_id = 5;

-- What you almost certainly need
SELECT id, name, price, stock_quantity FROM products WHERE category_id = 5;

The cost difference depends on your column set. If products has a description TEXT column averaging 2KB and you're fetching 500 rows per page, that's 1MB of text you're discarding. If there's a metadata JSONB column with nested attributes for analytics, the bloat is worse. And the database has to read all of that from disk or buffer cache regardless.

Most ORMs offer projection support. Use it:

// JPA / Spring Data — explicit projection interface
public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
}

List<ProductSummary> findByCategoryId(Long categoryId);
# Django — values() for column selection
Product.objects.filter(category_id=5).values('id', 'name', 'price')

The friction of writing explicit projections is real. The cost of not doing it is also real, just invisible until traffic grows.

Unbounded result sets

-- In a background job processing "all pending items"
SELECT * FROM events WHERE processed = false;

If events has 2 million unprocessed rows, this query returns all 2 million to the application layer at once. Memory usage spikes. The database holds a cursor open for the duration. Network transfer takes seconds. If the application process crashes mid-processing, you've made no progress and have to restart.

Batch it:

-- Process in chunks of 1000
SELECT id, payload FROM events
WHERE processed = false
ORDER BY id
LIMIT 1000;

-- After processing, advance the cursor
SELECT id, payload FROM events
WHERE processed = false AND id > :last_processed_id
ORDER BY id
LIMIT 1000;

This is keyset pagination applied to batch jobs. It keeps memory bounded, allows restartability, and lets you control throughput.

N+1 queries: fetching related data one at a time

This is the most widely documented over-fetching pattern, and it's still prevalent:

# Fetch 100 orders, then fetch the user for each one
orders = Order.objects.filter(status='pending')[:100]
for order in orders:
    print(order.user.name)  # N additional queries

This executes 101 queries: one for the orders, and one per order for the user. With 100 orders, the database is hit 101 times. With 1,000 orders, 1,001 times.

The fix is eager loading:

# Django: select_related fetches users in a single JOIN query
orders = Order.objects.filter(status='pending').select_related('user')[:100]
// JPA: JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = 'PENDING'")
List<Order> findPendingOrdersWithUsers(Pageable pageable);

The distinction between select_related (JOIN, good for single related objects) and prefetch_related (separate query + in-memory join, good for many-to-many) in Django maps to a real execution difference — choose based on cardinality.

Fetching for validation when existence is enough

-- You just want to know if the record exists
SELECT * FROM subscriptions WHERE user_id = 42 AND status = 'active';

If you're using the result for an existence check, fetch a constant:

SELECT 1 FROM subscriptions WHERE user_id = 42 AND status = 'active' LIMIT 1;

With a covering index on (user_id, status), this is an index-only scan that never touches the heap. The difference matters in hot code paths called thousands of times per second.

Pagination defaults that are too large

Default page sizes of 100 or 500 rows were set by someone who didn't think about them. For a dashboard showing a table, 20–50 rows is the practical maximum. For an API consumed by mobile clients, even 20 is often too many.

Set conservative defaults and let clients request more explicitly with a hard cap:

-- Server enforces maximum regardless of what client requests
SELECT id, name, price
FROM products
WHERE category_id = :category_id
ORDER BY name
LIMIT LEAST(:requested_limit, 50)
OFFSET :offset;

The immediate action

Run your three highest-traffic queries through EXPLAIN ANALYZE and check the width field in the output (PostgreSQL). This tells you the average row size in bytes being processed at each stage. Then check what your application actually uses from those rows. The gap between those two numbers is your over-fetching tax. Eliminate it by projecting only the columns you need, and if the width is still large, investigate whether your column types are appropriately sized for the data they store.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Getting Paid on Time Is a System. Here Is How to Build One.

Late payments do not happen because clients are malicious — they happen because the contractor never built a system that made paying on time the path of least resistance.

Read more

How to Write Rails Migrations Without Causing Downtime

Most Rails migration patterns that work fine in development will lock tables in production. Here is the mental model and specific techniques for schema changes that deploy safely on live databases.

Read more

Fixed Price vs Time & Materials — Which Contract Model Works Better for Backend Projects

Fixed price contracts transfer risk to the contractor and invite scope games; time and materials contracts transfer risk to the client and require active oversight — understanding which risk you are better positioned to manage determines which model to use.

Read more

Stop Copying Everything Into Your Docker Image

Most Dockerfiles copy the entire project directory into the image without a second thought. That decision bloats your image, leaks sensitive files, and slows every build — and a .dockerignore file is only part of the fix.

Read more