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

by Arif Ikhsanudin, Backend Developer

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

Logging Across Microservices Is Useless If You Can't Connect the Dots

Logs from individual services are only as useful as your ability to correlate them across service boundaries. Without structured logging and a consistent correlation ID strategy, your logs are evidence without context.

Read more

Java Streams Are Lazy — What That Means for Performance and Correctness

Stream intermediate operations do not execute until a terminal operation is called. This laziness enables short-circuiting, infinite streams, and fusion optimizations — and causes correctness bugs when side effects are assumed to have already fired.

Read more

How to Say No to a Client Request Without Losing the Relationship

Saying no is a skill. Done poorly, it creates conflict. Done well, it builds respect and keeps the working relationship intact.

Read more

Helsinki Has 600,000 People — Finding a Senior Backend Developer Here Is Harder Than It Sounds

Helsinki punches well above its size in tech. Its backend talent pool is still small enough to feel it.

Read more