Index with Sort #

When developers add ORDER BY created_at DESC to a query, a common assumption is “this must be fast because created_at has an index”. The reality isn’t that simple. The existing index may not be used for sorting, and the database will do something called filesort — collecting all matching rows, copying them to a temporary memory area or disk, then sorting them from scratch. For a query with LIMIT 20, this means the database sorts tens of thousands of rows just to take the top 20. This article covers when indexes truly eliminate sorting, why column order and direction in an index matter so much, how to detect filesort in EXPLAIN, the mixed ASC/DESC cases often handled wrong, and how to design indexes that make sorting free.

What Filesort Is and Why It’s Expensive #

Using filesort in the EXPLAIN Extra column is a signal that the database can’t use the order already in an index — it must compute a new order from scratch.

-- A query that triggers filesort
EXPLAIN SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;

-- If the only existing index is: INDEX (status)
-- +------+-------+---------------+------+---------+--------------------------+
-- | type | key   | possible_keys | rows | filtered | Extra                   |
-- +------+-------+---------------+------+---------+--------------------------+
-- | ref  | idx_  | idx_status    | 85000| 100.00  | Using where; Using      |
-- |      | status|               |      |         | filesort                |
-- +------+-------+---------------+------+---------+--------------------------+

-- "Using where" → the status filter is used
-- "Using filesort" → the database collects 85,000 rows,
--   then sorts all of them to take the top 20
-- LIMIT 20 doesn't help reduce the sort cost!

The process happening during filesort:

The filesort process for the query above:
──────────────────────────────────────────────────────────────
  1. Scan the idx_status index → find 85,000 status='published' rows
  2. Read 85,000 rows from the table
  3. Copy them into the sort buffer (if it fits) or a temporary file on disk
  4. Sort 85,000 rows by created_at DESC
  5. Take the first 20 rows
  6. Return them to the client

  Cost: O(n log n) for n = 85,000
  Memory: sort_buffer_size (MySQL default: 256KB – 1MB)
  Disk: if data > sort_buffer → a tmpfile on disk (very slow)
──────────────────────────────────────────────────────────────
  LIMIT 20 only saves the cost of step 6.
  Steps 1-5 still run fully for all 85,000 rows.

How Indexes Eliminate Sorting #

When an index already covers the sort column in the right order, the database doesn’t need to sort at all — it just scans the index from the right position and the data is already in the requested order.

-- Create a composite index covering WHERE and ORDER BY
CREATE INDEX idx_articles_status_created
    ON articles (status, created_at DESC);

-- The same query now:
EXPLAIN SELECT id, title, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;

-- +-------+-----------------------------+------+---------+-----------------------+
-- | type  | key                         | rows | filtered | Extra                |
-- +-------+-----------------------------+------+---------+-----------------------+
-- | range | idx_articles_status_created |   20 | 100.00  | Using index condition |
-- +-------+-----------------------------+------+---------+-----------------------+

-- rows = 20  ← the database only reads 20 rows!
-- No "Using filesort"
-- Extra: "Using index condition" → an index scan with a condition

Why does this work? Because the B-Tree index (status, created_at DESC) already stores data in this order:

Index contents (status, created_at DESC):
──────────────────────────────────────────────────────────────
  [published, 2026-04-18 10:30:00] → row ptr
  [published, 2026-04-18 09:15:00] → row ptr
  [published, 2026-04-17 22:00:00] → row ptr
  [published, 2026-04-17 18:45:00] → row ptr
  ... (already sorted by created_at DESC within the status=published group)
  [draft, 2026-04-18 11:00:00] → row ptr
  [draft, ...]
──────────────────────────────────────────────────────────────

The database only needs to:
  1. Seek to the [published, ...] position in the B-Tree
  2. Scan 20 entries downward
  3. Done — no runtime sort

The Fundamental Rule: Equality Before Sort #

The most often violated rule when designing indexes for sorting is placing the sort column in the wrong position within a composite index.

-- Query: published articles from a specific category, newest first
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
  AND category_id = 5
ORDER BY created_at DESC
LIMIT 20;

-- ANTI-PATTERN 1: sort column before equality columns
CREATE INDEX idx_wrong_1 ON articles (created_at DESC, status, category_id);
-- → The database can't use the index for the status and category_id filters
--   because they aren't index prefixes
-- → Filesort appears

-- ANTI-PATTERN 2: sort column in the middle of equality columns
CREATE INDEX idx_wrong_2 ON articles (status, created_at DESC, category_id);
-- → The database can use status, but after hitting created_at (the sort column)
--   it can't continue to category_id in the next position
-- → category_id can't be used through this index
-- → Filesort can appear

-- CORRECT: all equality columns first, sort column last
CREATE INDEX idx_correct ON articles (status, category_id, created_at DESC);
-- → The database: seek to (status='published', category_id=5)
-- → In that position, rows are already sorted by created_at DESC
-- → Take 20, done — no filesort

The rule:

The correct order in a composite index for WHERE + ORDER BY:
──────────────────────────────────────────────────────────────
  1. Equality columns (=) with the highest selectivity
  2. Following equality columns (=)
  3. Range columns (>, <, BETWEEN) if any
  4. ORDER BY columns — must be in the very last position

  Note: after a range column, the ORDER BY column
  may not be usable for avoiding a sort.
──────────────────────────────────────────────────────────────

The Range Interaction with Sorting #

This is the most misunderstood case: when there’s a range condition (>, <, BETWEEN) before the sort column, the index can’t eliminate the sort.

-- Query: articles created in the last 7 days, newest first
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
  AND created_at >= NOW() - INTERVAL 7 DAY  -- ← range condition
ORDER BY created_at DESC
LIMIT 20;

-- Index: (status, created_at DESC)
-- What happens?
-- The database seeks to status='published', created_at >= [threshold]
-- Within that range, the data is ALREADY sorted by created_at DESC
-- → No filesort! A range on the same sort column isn't a problem.

-- But if the range isn't on the sort column:
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
  AND price BETWEEN 100000 AND 500000  -- ← a range on ANOTHER column
ORDER BY created_at DESC
LIMIT 20;

-- Ideal index: (status, price, created_at DESC)
-- Problem: after finding rows within the price range,
-- those rows are NO LONGER sorted by created_at
-- The database still must sort → filesort appears
-- This is a fundamental B-Tree index limitation

Mixed Sort Directions: ASC and DESC Together #

The most often mishandled case is when a query needs different sort directions for different columns — for example ORDER BY price ASC, created_at DESC.

In MySQL #

MySQL before 8.0 doesn’t support descending indexes — all index columns are stored ASC. MySQL 8.0+ supports them:

-- MySQL 8.0+: per-column descending indexes
CREATE INDEX idx_articles_price_created
    ON articles (price ASC, created_at DESC);

-- The query leveraging this:
SELECT id, title, price, created_at
FROM articles
WHERE status = 'published'
ORDER BY price ASC, created_at DESC
LIMIT 20;

-- EXPLAIN in MySQL 8.0+:
-- Extra: "Using index condition"  ← no filesort!

-- In MySQL 5.7 or older:
-- DESC in indexes is ignored, everything is stored ASC
-- The query ORDER BY price ASC, created_at DESC will trigger filesort
-- because the index doesn't store created_at in DESC order

How to detect whether MySQL is using a descending index:

-- Check whether the index is really stored as DESC
SHOW CREATE TABLE articles;
-- Look for DESC in the index definition:
-- KEY `idx_articles_price_created` (`price`,`created_at` DESC)

In PostgreSQL #

PostgreSQL is more flexible — it supports mixed sort directions from the start:

-- PostgreSQL: specify the direction per column
CREATE INDEX idx_articles_price_created
    ON articles (price ASC NULLS LAST, created_at DESC NULLS FIRST);

-- NULLS FIRST / NULLS LAST determines NULL value positions in sorting
-- Important for preventing "sort mismatches" that still trigger runtime sorts

-- Query:
SELECT id, title, price, created_at
FROM articles
WHERE status = 'published'
ORDER BY price ASC NULLS LAST, created_at DESC NULLS FIRST
LIMIT 20;

-- EXPLAIN ANALYZE:
-- -> Index Scan using idx_articles_price_created on articles
--    (cost=0.43..95.23 rows=20 width=44) (actual time=0.028..0.089 rows=20 loops=1)
-- No Sort node → the index scan directly produces the right order

Backward Index Scans #

When a query requests sorting opposite to the index’s stored direction, the database can do a backward scan — reading the index from back to front. This is almost as efficient as a forward scan for most cases.

-- Index: (status, created_at) — implicitly ASC
CREATE INDEX idx_articles_status_created ON articles (status, created_at);

-- ASC query: forward scan
SELECT id FROM articles
WHERE status = 'published'
ORDER BY created_at ASC
LIMIT 20;
-- → Scans the index from the front: efficient

-- DESC query: backward scan
SELECT id FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;
-- → Scans the index from the back: almost equally efficient
-- EXPLAIN Extra: "Backward index scan" (MySQL 8.0+)
-- or "Index Scan Backward" (PostgreSQL)
-- No filesort!

Backward scans have slightly more overhead than forward scans because the B-Tree navigation differs, but the difference is far smaller than filesort. For most cases, one index for both directions (ASC and DESC) is enough without needing two separate indexes.

When are two separate indexes (ASC and DESC) needed?
──────────────────────────────────────────────────────────────
  Two indexes aren't needed if:
    ✓ Only one column is sorted
    ✓ Queries always use a single direction
    ✓ No mixed directions in queries

  Consider two indexes or a descending index if:
    ✗ Queries need mixed directions (price ASC, created_at DESC)
    ✗ A backward scan is proven slow on a very frequent query
       (rare, verify with benchmarks)
──────────────────────────────────────────────────────────────

Covering Indexes for Sorting: Eliminating Table Lookups #

When all the columns a query needs (SELECT, WHERE, ORDER BY) are in the index, the database doesn’t need to read rows from the table at all — this is called an index-only scan or covering index.

-- A frequently run query: the latest articles list with minimal data
SELECT id, title, status, created_at
FROM articles
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20;

-- A regular index: (status, created_at DESC)
-- The database: uses the index for the seek and sort
-- But still needs to read the table for the 'title' and 'id' columns
-- Extra: "Using index condition" (still a table lookup)

-- Covering index: add all SELECT columns
CREATE INDEX idx_articles_covering
    ON articles (status, created_at DESC, title, id);
-- Or more concisely since 'id' is the primary key always in the index:
CREATE INDEX idx_articles_covering
    ON articles (status, created_at DESC, title);

-- Now:
-- Extra: "Using index"  ← index-only scan, no table lookup
-- Rows read: exactly 20 — no more

-- Performance comparison:
-- Without covering:  read 20 index entries + 20 table lookups
-- With covering: read 20 index entries only — 2× faster for heavy reads
Covering indexes are most beneficial for endpoints called frequently with the same results repeated — for example product listing pages or article feeds. Add columns to a covering index only if they’re small (integers, timestamps, short strings). Don’t put TEXT or JSON columns into covering indexes because they make the index very large and slow down writes.

Real Cases Often Designed Wrong #

Case 1: User Activity Feeds #

-- Query: the 20 latest activities from one user
SELECT id, type, message, created_at
FROM activity_logs
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;

-- ANTI-PATTERN: an index only on user_id
CREATE INDEX idx_logs_user ON activity_logs (user_id);
-- → user_id filter: ✓ (maybe 50,000 logs for this user)
-- → created_at sort: ✗ (filesort over 50,000 rows)

-- CORRECT: a composite index of user_id + created_at
CREATE INDEX idx_logs_user_created ON activity_logs (user_id, created_at DESC);
-- → Seek to user_id=42, rows already sorted by created_at DESC
-- → Take 20, done — zero filesort
-- → rows in EXPLAIN: 20 (not 50,000)

Case 2: Dashboards with Multiple Filters #

-- Query: the latest orders with status and tenant filters
SELECT id, order_number, total, created_at
FROM orders
WHERE tenant_id = 'abc123'
  AND status    = 'pending'
ORDER BY created_at DESC
LIMIT 20;

-- Index: (tenant_id, status, created_at DESC)
-- ✓ Equality: tenant_id
-- ✓ Equality: status
-- ✓ Sort: created_at DESC — in the last position, correct

-- Verify with EXPLAIN:
EXPLAIN SELECT id, order_number, total, created_at
FROM orders
WHERE tenant_id = 'abc123' AND status = 'pending'
ORDER BY created_at DESC LIMIT 20;

-- Expected:
-- type: range or ref
-- key: idx_orders_tenant_status_created
-- rows: ≈ 20 (not thousands)
-- Extra: "Using index condition" (no "Using filesort")

Case 3: Sorting with Nullable Columns #

-- Query: active products sorted by featured_at (can be NULL)
-- featured_at NULL means the product isn't featured
SELECT id, name, featured_at
FROM products
WHERE status = 'active'
ORDER BY featured_at DESC  -- NULLs will be at the bottom
LIMIT 20;

-- Problem: NULLs in sorting can cause runtime sorts
-- In MySQL: NULL is considered the smallest (appears last in DESC)
-- In PostgreSQL: controllable with NULLS FIRST / NULLS LAST

-- The right index in MySQL:
CREATE INDEX idx_products_status_featured
    ON products (status, featured_at DESC);
-- NULLs will be at the end → featured products appear first

-- In PostgreSQL with NULLS LAST:
CREATE INDEX idx_products_status_featured
    ON products (status, featured_at DESC NULLS LAST);
-- Make sure the query uses ORDER BY featured_at DESC NULLS LAST
-- to match the index and avoid triggering runtime sorts

Detecting and Fixing Filesort in Production #

How to Find Queries with Filesort #

-- MySQL: enable the slow query log with minimal data
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;  -- log queries > 100ms
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- Then analyze with:
-- mysqldumpslow -s t /var/log/mysql/slow.log
-- Look for queries with "filesort" in their EXPLAIN

-- PostgreSQL: pg_stat_statements for slow queries
SELECT query,
       calls,
       total_exec_time / calls AS avg_ms,
       rows / calls AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- Run EXPLAIN ANALYZE for each suspected query

Reading EXPLAIN: Filesort Signals #

-- Problem signals in MySQL EXPLAIN:
-- Extra: "Using filesort"           → runtime sort, not index sort
-- Extra: "Using temporary"         → the sort needs a temporary table (more expensive)
-- rows: a large number with a small LIMIT → scanning too many rows

-- Problem signals in PostgreSQL EXPLAIN:
-- -> Sort  (cost=...) → a Sort node exists → filesort
-- -> Seq Scan         → no index used at all

-- Good EXPLAIN targets:
-- MySQL: Extra contains "Using index" or "Using index condition",
--        does NOT contain "Using filesort"
-- PostgreSQL: no Sort node above an Index Scan

Anti-Patterns to Avoid #

-- ✗ Anti-pattern 1: indexes only for filters, forgetting the sort
CREATE INDEX idx_orders_status ON orders (status);
-- Query: WHERE status = ? ORDER BY created_at DESC LIMIT 20
-- → The filter uses the index, but the sort is still a filesort
-- ✓ Solution: CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC)

-- ✗ Anti-pattern 2: sort column before equality columns
CREATE INDEX idx_orders_created_status ON orders (created_at DESC, status);
-- Query: WHERE status = 'paid' ORDER BY created_at DESC
-- → The index isn't optimal because status isn't a prefix
-- ✓ Solution: (status, created_at DESC) — equality first

-- ✗ Anti-pattern 3: mixed ASC/DESC without database support
-- MySQL 5.7: ORDER BY price ASC, created_at DESC
CREATE INDEX idx_price_created ON products (price, created_at);
-- → created_at is stored ASC in the index, but the query asks for DESC
-- → Filesort appears even though the index exists
-- ✓ Solution: upgrade to MySQL 8.0+ and use (price ASC, created_at DESC)

-- ✗ Anti-pattern 4: functions in ORDER BY
ORDER BY DATE(created_at) DESC  -- functions kill index sorts
-- ✓ Solution: ORDER BY created_at DESC (use a range WHERE if a date filter is needed)

-- ✗ Anti-pattern 5: sorting without LIMIT on public endpoints
SELECT * FROM products WHERE status = 'active' ORDER BY created_at DESC;
-- → Filesorts the entire dataset to return it all
-- → Without LIMIT: the database sorts thousands/millions of rows
-- ✓ Solution: always add LIMIT, use pagination

Index-for-Sort Checklist #

WHEN DESIGNING A NEW INDEX FOR A QUERY WITH ORDER BY:
  □ Are equality columns placed before the sort column in the index?
  □ Does the index sort direction (ASC/DESC) match the query?
     In MySQL 8+: you can specify DESC per column
     In MySQL 5.7: everything is ASC, use backward scans for DESC
     In PostgreSQL: specify per column + NULLS FIRST/LAST
  □ Is there a range column between equality and sort? If so:
     → The sort probably can't be avoided — consider other strategies
  □ Verified with EXPLAIN that "Using filesort" doesn't appear?
  □ Are the rows in EXPLAIN close to the LIMIT value (not thousands for a LIMIT 20)?

FOR COVERING INDEXES:
  □ Are all SELECT columns in the index (including the sort column)?
  □ No large TEXT/BLOB/JSON columns in the covering index?
  □ Does EXPLAIN show "Using index" (not "Using index condition")?

FOR QUERIES WITH MIXED SORTS:
  □ Checked whether the MySQL version supports descending indexes?
  □ In PostgreSQL: are NULLS FIRST/LAST consistent between the index and query?
  □ Benchmarked whether filesort here is truly a performance problem?

Summary #

  • Using filesort in EXPLAIN means sorting happens at runtime — the database collects all matching rows, sorts them, then takes the LIMIT. For 85,000 rows with LIMIT 20, that’s still a full sort of 85,000 rows.
  • LIMIT doesn’t save queries without sort indexes — LIMIT only saves fetching rows after the sort finishes. The sort operation itself still processes every row matching the WHERE.
  • Equality columns must come before the sort column in indexes(status, created_at DESC) is optimal for WHERE status = ? ORDER BY created_at DESC. (created_at DESC, status) isn’t.
  • Range conditions before the sort column make filesort elimination difficult — if there’s a BETWEEN or > on a different column than the sort column, the index usually can’t fully eliminate the sort.
  • Backward scans are almost as efficient as forward scansINDEX (created_at) can serve both ORDER BY created_at ASC and ORDER BY created_at DESC. No need for two separate indexes for two sort directions.
  • Mixed ASC/DESC needs descending indexesORDER BY price ASC, created_at DESC needs INDEX (price ASC, created_at DESC). In MySQL 5.7 this isn’t possible and always filesorts; it requires MySQL 8.0+.
  • Covering indexes eliminate table lookups — if all SELECT columns are in the index, EXPLAIN shows “Using index” and the database never touches the main table. Very effective for frequently called endpoints.
  • Always verify with EXPLAIN after adding indexes — designs that look correct on paper may not be used by the query planner. EXPLAIN is the only way to prove an index truly eliminates filesort.

← Previous: Avoid ORM   Next: Avoid Over-Indexing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact