PgDeck

Filter PostgreSQL data

Filtering rows in PostgreSQL.

Most filtering problems are one of three things: NULL not behaving like a value, a timestamp range that quietly drops a day, or a pattern match that cannot use the index you built for it.

Short answer: Use ILIKE for case-insensitive matching, IS NULL rather than = NULL, and half-open ranges for timestamps — >= start AND < next_start — because BETWEEN includes the end and will miss everything after midnight on the final day. A leading wildcard in LIKE cannot use a normal B-tree index; that needs pg_trgm.

PgDeck PostgreSQL desktop client with schema navigation, table filtering, row editing, and SQL tools

Why developers use PgDeck

PgDeck is shaped around PostgreSQL tasks that happen every day: connect, inspect, filter, edit, query, manage structure, copy, and export.

ILIKE is case-insensitive; LIKE is not
= NULL is never true — use IS NULL
BETWEEN on timestamps silently drops the final day
NOT IN with any NULL in the list returns zero rows
A leading wildcard needs pg_trgm, not a B-tree index

Secure connections

Save PostgreSQL connection details with encrypted passwords, SSL settings, URL import, testing, search, edit, and delete actions.

Schema and data grid

Browse schemas and tables, search the table list, pin favorites, filter rows, sort columns, resize columns, and paginate results.

SQL workspace

Run SELECT and non-SELECT statements from multi-tab SQL editors with syntax highlighting and interactive query results.

Staged editing

Edit cells, add rows, duplicate rows, set NULL, stage deletes, review pending changes, and commit everything in one transaction.

How to filter PostgreSQL rows correctly

  1. Case-insensitive text: ILIKE

    LIKE is case-sensitive. ILIKE is PostgreSQL's case-insensitive version and is almost always what you want when searching names or emails typed by a human. For whole-value comparison, lower() on both sides works too, but it needs an expression index to stay fast.

    SELECT * FROM users WHERE email ILIKE '%@example.com';
    
    -- Equivalent for exact matching, needs an expression index to be fast:
    SELECT * FROM users WHERE lower(email) = lower('Someone@Example.com');
  2. NULL needs IS NULL

    NULL means unknown, so any comparison with it returns unknown rather than true — including = NULL and even NULL = NULL. This is the single most common filtering mistake in SQL. Use IS NULL and IS NOT NULL, and remember that NOT IN with a list containing a NULL returns no rows at all.

    SELECT count(*) FROM users WHERE deleted_at IS NULL;
    
    -- Returns nothing if the subquery yields any NULL:
    SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM bans);
    -- Safe version:
    SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM bans b WHERE b.user_id = u.id);
  3. Timestamp ranges: half-open, not BETWEEN

    BETWEEN is inclusive at both ends, so a range ending at a date drops every row after midnight on that day. With timestamps, always use >= the start and < the start of the next period. This bug is invisible until someone notices a report is missing a day.

    -- Misses everything after 2026-01-31 00:00:00
    SELECT * FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31';
    
    -- Correct
    SELECT * FROM orders
    WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
  4. JSONB and arrays

    ->> extracts a field as text, and @> tests containment. Containment is the one that can use a GIN index, so prefer it for filters on jsonb columns. For arrays, = ANY is the readable way to test membership.

    -- Field equality
    SELECT * FROM events WHERE payload->>'type' = 'signup';
    
    -- Containment, GIN-indexable
    SELECT * FROM events WHERE payload @> '{"type": "signup"}';
    
    -- Array membership
    SELECT * FROM posts WHERE 'postgres' = ANY(tags);
  5. Check whether the filter uses an index

    EXPLAIN ANALYZE tells you what actually happened. A sequential scan on a small table is fine; on a large one it means your filter could not use the index. A leading wildcard is the usual cause — 'foo%' can use a B-tree, '%foo' cannot, and that needs a trigram index.

    EXPLAIN ANALYZE SELECT * FROM users WHERE email ILIKE '%@example.com';
    
    -- Makes leading-wildcard and ILIKE searches indexable:
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    CREATE INDEX users_email_trgm ON users USING gin (email gin_trgm_ops);
  6. Or filter visually

    For exploration, a GUI client's filters are faster than writing predicates. PgDeck's filters are type-aware — a boolean column offers true and false, a timestamp offers a date input, and global search uses ILIKE — so the NULL and case-sensitivity traps above are handled for you.

Three-valued logic explains most surprises

SQL predicates return true, false, or unknown, and only true rows come back. Any comparison involving NULL is unknown, so a row with a NULL in the filtered column is excluded by both col = 'x' and col <> 'x'. That is correct behaviour and it catches everyone. When a filter returns fewer rows than expected, check for NULLs in the column first.

Why the index is not being used

PostgreSQL will ignore an index if the filter cannot use it or if the planner thinks a scan is cheaper. Applying a function to the column — lower(email) — needs a matching expression index. A leading wildcard cannot use a B-tree at all. And on a small table a sequential scan genuinely is faster, so an unused index there is not a problem to solve.

Filtering on jsonb

->> gives you a text value and works for straightforward equality, but it cannot use a GIN index. The containment operator @> can, which makes it the right choice on large tables. If you filter on the same jsonb key constantly, an expression index on that specific key is usually better than a general GIN index on the whole column.

Time zones in date filters

Comparing a timestamptz against a bare date string converts using the session TimeZone setting, so the same query can return different rows for two people in different zones. When the boundary matters, be explicit about the zone rather than relying on whatever the session happens to be set to.

Filters are also a safety mechanism

Most destructive UPDATE and DELETE incidents come from a WHERE clause that matched more rows than intended. Building the filter as a SELECT and checking the count before attaching an action to it costs nothing, and it is the reason this page is worth reading even if you never write a report.

Related PostgreSQL guides

More on choosing, comparing, and running a PostgreSQL desktop client.

How do I do a case-insensitive search in PostgreSQL?

Use ILIKE, which is PostgreSQL's case-insensitive LIKE: WHERE email ILIKE '%@example.com'. For exact comparison you can use lower() on both sides, but that needs an expression index to remain fast on a large table.

Why does my WHERE clause return no rows?

Most often NULL. Any comparison with NULL is unknown rather than true, so both col = 'x' and col <> 'x' exclude rows where col is NULL. Use IS NULL to test for it. NOT IN with a list containing any NULL also returns zero rows.

Why is my date range missing the last day?

BETWEEN is inclusive of both endpoints, but a date literal means midnight, so BETWEEN '2026-01-01' AND '2026-01-31' excludes everything after 00:00 on the 31st. Use >= '2026-01-01' AND < '2026-02-01' instead.

Why is my filter slow even though I created an index?

Common causes: a leading wildcard in LIKE, which a B-tree cannot use and which needs a pg_trgm GIN index; a function applied to the column without a matching expression index; or a table small enough that a sequential scan is genuinely cheaper. EXPLAIN ANALYZE will tell you which.

How do I filter a JSONB column?

Use ->> to extract a field as text for simple equality, or the containment operator @> for filters that can use a GIN index. On large tables prefer @>, or create an expression index on the specific key you filter by most.