Partial Index

4 min read
← Back to Blogs
Partial Index

DISCLAIMER: Image is generated using ChatGPT.



Confession time, I have never heard of this term, Partial Index, in my entire programming career. I was having deep conversation about database performance with my friend (DBA by profession) and this term just popped out.

As always, I decided to dig deep and find out more about it.

A Partial Index (also called a Filtered Index) is an index built over a subset of rows in a table, defined by a WHERE clause.

Standard indexes map every single row in a table where as a partial index only includes rows that satisfy its specific condition.

PostgreSQL has supported partial indexes since version 7.2 using a WHERE clause in CREATE INDEX.

MySQL does not support a WHERE clause in CREATE INDEX statements.

However, you can mimic a partial index in MySQL (8.0+) using Virtual Generated Columns or Functional Indexes that return NULL for excluded rows (since MySQL skips NULL values in standard B-tree index paths).

Let setup docker container quickly, so that we demo this behaviour.

Docker Configuraton

File: docker-compose.yml

services:
  postgres-db:
    image: postgres:latest
    container_name: demo_postgres
    restart: always
    environment:
      POSTGRES_USER: demo_user
      POSTGRES_PASSWORD: demo_password
      POSTGRES_DB: demo_db
    ports:
      - "5434:5432"

  mysql-db:
    image: mysql:latest
    container_name: demo_mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: demo_password
      MYSQL_DATABASE: demo_db
      MYSQL_USER: demo_user
      MYSQL_PASSWORD: demo_password
    ports:
      - "3307:3306"

volumes:
  pgdata:
  mysqldata:

Start Container

$ docker compose up -d
[+] up 3/3
 ✔ Network partial-index_default Created                                0.0s
 ✔ Container demo_postgres       Started                                0.3s
 ✔ Container demo_mysql          Started                                0.4s

PostgreSQL

Lets open a connection to PostgreSQL session:

$ docker exec -it demo_postgres psql -U demo_user -d demo_db
psql (18.4 (Debian 18.4-1.pgdg13+1))
Type "help" for help.

demo_db=#

Create table orders first:

demo_db=# CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INT,
    status      VARCHAR(20),
    amount      DECIMAL(10,2)
);
CREATE TABLE
demo_db=#

Populate sample data:

demo_db=# INSERT INTO orders (customer_id, status, amount)
SELECT
    (random() * 1000)::int,
    CASE WHEN random() < 0.9 THEN 'completed' ELSE 'pending' END,
    (random() * 500)::numeric(10,2)
FROM generate_series(1, 100000);
INSERT 0 100000
demo_db=#

Create the Partial Index:

demo_db=# CREATE INDEX idx_postgres_pending_orders
ON orders (customer_id)
WHERE status = 'pending';
CREATE INDEX
demo_db=#

Verify Execution Plan:

demo_db=# EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
                                                               QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=4.36..40.99 rows=10 width=23) (actual time=0.025..0.030 rows=6.00 loops=1)
   Recheck Cond: ((customer_id = 42) AND ((status)::text = 'pending'::text))
   Heap Blocks: exact=6
   Buffers: shared hit=6 read=2
   ->  Bitmap Index Scan on idx_postgres_pending_orders  (cost=0.00..4.36 rows=10 width=0) (actual time=0.018..0.018 rows=6.00 loops=1)
         Index Cond: (customer_id = 42)
         Index Searches: 1
         Buffers: shared read=2
 Planning:
   Buffers: shared hit=52 read=2
 Planning Time: 0.416 ms
 Execution Time: 0.065 ms
(12 rows)

You can clearly see, PostgreSQL uses an Index Scan using idx_postgres_pending_orders because the WHERE condition matches the index filter.

If you search for status = ‘completed’, PostgreSQL skips this index entirely and runs a Sequential Scan.

demo_db=# EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 AND status = 'completed';
                                               QUERY PLAN
---------------------------------------------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..2227.00 rows=89 width=23) (actual time=0.036..4.988 rows=93.00 loops=1)
   Filter: ((customer_id = 42) AND ((status)::text = 'completed'::text))
   Rows Removed by Filter: 99907
   Buffers: shared hit=727
 Planning:
   Buffers: shared hit=3 read=3
 Planning Time: 0.407 ms
 Execution Time: 5.015 ms
(8 rows)

MySQL

Let’s connect to MySQL database session using password, demo_password.

$ docker exec -it demo_mysql mysql -u demo_user -p demo_db
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 9.7.1 MySQL Community Server - GPL

Copyright (c) 2000, 2026, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

Create table, orders:

mysql> CREATE TABLE orders (
    ->     id          INT AUTO_INCREMENT PRIMARY KEY,
    ->     customer_id INT,
    ->     status      VARCHAR(20),
    ->     amount      DECIMAL(10,2)
    -> );
Query OK, 0 rows affected (0.030 sec)
mysql>

Populate sample data, first increasing the maximum recursion depth.

mysql> SET SESSION cte_max_recursion_depth = 100000;
Query OK, 0 rows affected (0.001 sec)

mysql> INSERT INTO orders (customer_id, status, amount)
    -> WITH RECURSIVE seq AS (
    ->     SELECT 1 AS n
    ->     UNION ALL
    ->     SELECT n + 1 FROM seq WHERE n < 100000
    -> )
    -> SELECT
    ->     FLOOR(RAND() * 1000),
    ->     IF(RAND() < 0.9, 'completed', 'pending'),
    ->     ROUND(RAND() * 500, 2)
    -> FROM seq;
Query OK, 100000 rows affected (0.365 sec)
Records: 100000  Duplicates: 0  Warnings: 0

mysql>

Now create the simulated Partial Index.

Add a virtual column that returns customer_id ONLY for ‘pending’ orders.

mysql> ALTER TABLE orders
    -> ADD COLUMN pending_customer_id INT GENERATED ALWAYS AS (
    ->     IF(status = 'pending', customer_id, NULL)
    -> ) VIRTUAL;
Query OK, 0 rows affected (0.017 sec)
Records: 0  Duplicates: 0  Warnings: 0

Index the virtual column:

mysql> CREATE INDEX idx_mysql_pending_orders ON orders (pending_customer_id);
Query OK, 0 rows affected (0.142 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql>

Verify Execution Plan

Index Lookup (Reads ~10 rows directly via B-tree)

mysql> EXPLAIN SELECT * FROM orders WHERE pending_customer_id = 42;
+----------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                  |
+----------------------------------------------------------------------------------------------------------+
| -> Index lookup on orders using idx_mysql_pending_orders (pending_customer_id = 42)  (cost=3.5 rows=10)  |
+----------------------------------------------------------------------------------------------------------+
1 row in set (0.009 sec)

Full Table Scan (Reads all ~100,000 rows from disk)

mysql> EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
+--------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                          |
+--------------------------------------------------------------------------------------------------+
| -> Filter: ((orders.`status` = 'pending') and (orders.customer_id = 42))  (cost=10073 rows=1000) |
|    -> Table scan on orders  (cost=10073 rows=100007)                                             |
+--------------------------------------------------------------------------------------------------+
1 row in set (0.005 sec)


Happy Hacking !!!