Database Query Profiler

Paste a query and read back which predicates a B-tree index could serve, which ones force a full scan, and what an index on those columns would have to look like. Static analysis, no connection to your server.

Load a sample

Query

Nothing profiled yet.

?

Waiting for a query

Press Profile query. Everything below is derived from the text of the statement, not from your server.

Rows read as written-
Rows read with the index plan-
Rows the client receives-
Predicates an index could serve-

Each finding names the pattern, the reason a B-tree cannot help, and the rewrite that fixes it.

A query gets slow for a short list of reasons, and almost all of them come down to how many rows the engine has to touch before the first row reaches your application. Ten milliseconds against 5,000 rows in staging turns into eleven seconds against 40 million rows in production, on the same query text, because the row count changed and nothing else did.

This page reads the statement as text. No credentials, no connection, no round trip. What comes back are the patterns a reviewer looks for first: predicates wrapped in functions, wildcards on the wrong side of a string, a sort with no index behind it, an OFFSET deep enough to make the engine count past a million rows it will throw away.

What the two row counts mean

The two numbers in the report are one model shown twice, under different assumptions.

The gap between them is the argument for building the index. When the two numbers are equal, no index helps at all, and the statement itself needs changing first.

These are order-of-magnitude figures from a fixed selectivity model, not measurements. A column holding two distinct values across ten million rows makes the equality estimate wrong by four orders of magnitude. Read them as a shape, then confirm against EXPLAIN on real data.

Sargable is the word behind most slow queries

Search ARGument ABLE. A predicate is sargable when the engine compares the raw indexed column against a constant, so a B-tree walk lands on the matching range directly. Break that shape and the index is dead weight, even though it exists and looks correct in the schema.

A function around the column

Date filters, case-insensitive matches

An index stores created_at. It does not store YEAR(created_at). The engine has to compute the function for every row before it knows which rows qualify, so the scan happens first and the filter second.

WHERE YEAR(created_at) = 2024
WHERE LOWER(email) = 'sam@northwind.co.uk'

Fix: move the work to the other side of the operator. WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' reads a contiguous range from the index. For the lowercase match, store a normalised column and index it, or build a functional index: PostgreSQL supports CREATE INDEX ON users (lower(email)), MySQL 8.0.13 and later support the equivalent as a functional key part.

A wildcard on the left of a LIKE

Search boxes

A B-tree is sorted by prefix. LIKE 'acme%' is a range read, and fast. LIKE '%acme%' has no known prefix, so there is no starting point in the tree and every row gets compared.

WHERE company_name LIKE '%acme%'

Fix: if a prefix match satisfies the feature, drop the leading wildcard. If it does not, a B-tree is the wrong structure for the job. Use a full text index, a PostgreSQL trigram index with pg_trgm, or a dedicated search service. Adding a plain index on the column will not change the timing.

A quoted literal against a numeric column

ORM parameter binding

Comparing a BIGINT column to '1042' makes the engine coerce one side. MySQL converts the column, not the literal, which turns the comparison into a function on the column and drops the index. The query returns correct rows, so nothing looks broken in testing.

WHERE user_id = '1042'

Fix: bind the parameter with its real type. In an ORM, check the column type in the model matches the schema. Collation mismatches between two joined tables cause the same failure on string keys, which is worth checking when a join key looks indexed and still scans.

Column order in a composite index is the whole decision

A composite index on (status, created_at) is not interchangeable with one on (created_at, status). The engine reads left to right and stops at the first column it cannot narrow. That is the leftmost prefix rule, and the ordering it implies has a name: equality, sort, range.

  1. Equality columns first. Every column matched with = or IN. Their order among themselves does not matter to the planner.
  2. Sort columns next. Columns from ORDER BY, in the order written, with matching direction. Placed here, the index returns rows already sorted and the separate sort step disappears.
  3. Range columns last. Anything with >, <, BETWEEN, or a prefix LIKE. A range column consumes the rest of the index, so nothing after it narrows anything.

Put the range column second and the sort still runs in memory. A query filtering status = 'active', ranging on created_at, and sorting by id reads best from (status, id, created_at) even though that feels backwards.

One caution the Index plan tab cannot apply for you. Every index is paid for on write, and duplicates are common: an index on (status) is already contained in (status, created_at) and should be dropped rather than added alongside. Check the existing index list on the table before running anything the tab produces.

Reading a real execution plan

Once you have server access, the plan beats any static guess. Two things to read first.

MySQL access types

In EXPLAIN output, the type column ranks from good to bad: const, eq_ref, ref, range, index, ALL. The last two both read the whole structure. Using filesort and Using temporary in the Extra column mean the sort or the grouping had no index behind it.

PostgreSQL node types

Seq Scan on a large table with a selective filter is the flag. Index Scan and Bitmap Heap Scan both use an index, the second when many rows match. Run EXPLAIN (ANALYZE, BUFFERS) and compare estimated rows against actual rows. A wide gap means stale statistics, so run ANALYZE before touching the query.

The number to watch in either engine is rows examined against rows returned. Reading 400,000 rows to return 20 is the definition of a missing index, whatever the total runtime says on a warm cache.

Rewrites worth trying, roughly in this order

MongoDB follows the same rule with different syntax

Equality, sort, range applies to compound indexes in MongoDB too, and the reordering that matters most is inside the pipeline. A $match placed after $lookup filters documents the server already joined. Move it to the first stage and the join runs against the surviving set instead.

$regex follows the LIKE rule: anchored with ^ it reads a range from the index, unanchored it scans the collection. $ne and $nin match nearly everything, so the planner reads the collection rather than the index. Confirm any change with .explain("executionStats") and read totalDocsExamined against nReturned.

Where this profiler stops

Static analysis has a hard ceiling, and knowing where it sits keeps you from trusting an A grade too far.

Next steps in the same workflow

Once the statement is readable, the SQL Formatter makes the clause structure obvious before you start moving predicates around. To try variants against sample data, the SQL Query Tester runs them in the browser.

When the slowness sits in application code rather than the statement, the Code Complexity Analyzer and the Performance Profiler cover that side.

Your query never leaves the browser. Parsing, scoring, and index generation all run in local JavaScript, so no schema names or literals travel to Toolexe or anywhere else.

Query profiling questions

What the report is based on, and where a real execution plan takes over.

Does this connect to my database and run the query?

No. There is no connection and no credential field anywhere on the page. The statement is analysed as text in your browser. That is why the report talks about patterns and modeled row counts rather than milliseconds, and why you should confirm anything it suggests with EXPLAIN on real data.

The rows read numbers look wrong for my table.

They will be, in the specific case. Selectivity here is a fixed factor per predicate type: heavy for equality, light for a range. Real selectivity depends on how many distinct values the column holds. A status column with three values across ten million rows makes the equality estimate far too optimistic. Read the two numbers as a ratio, not as a count.

Why does the index plan put ORDER BY columns before the range column?

Because a range predicate consumes the rest of the index. Once the engine reads a range on created_at, every column after it in the index is unordered relative to the surviving rows, so the sort has to run separately. Equality, then sort, then range keeps the ordering usable. This is the same rule MongoDB documents as ESR.

Should I create every index it suggests?

No. Each index slows down writes and takes disk. Check your existing indexes first, because a suggestion for (status, created_at) is redundant if an index on (status, created_at, id) already exists, and it makes a plain index on (status) droppable. Add one index, measure, then decide on the next.

It flagged nothing but the query is still slow.

Then the cause is outside the statement text. Common ones are lock contention from a long transaction, a cold buffer pool, stale statistics sending the planner down a bad path, an N+1 loop in application code running the query thousands of times, or network round trips on a large result set. Start with EXPLAIN ANALYZE and the lock and process lists.

Does it handle stored procedures, CTEs, and window functions?

Partly. Simple CTEs and window functions parse well enough for the pattern checks to fire, but clause boundaries inside nested subqueries are detected with patterns rather than a full grammar, so table and predicate extraction can drift on long statements. Multi-statement procedure bodies are not supported. Profile one statement at a time.

What does the letter grade actually score?

It starts at 100 and subtracts a weight for each finding, heaviest for patterns that guarantee a full scan such as a leading wildcard or a function around an indexed column. It scores the shape of the statement only. A grade of A on a query that runs against an unindexed 200 million row table still runs slowly.