Mert Özen Further With Every Line
SEO & GEO Analytics and Measurement — #02

The Search Console Performance Report in Depth: Position, CTR, Regex Filters, Branded Queries and Query Groups

Mert Özen Nov 15, 2026 11 min 5 views
The Search Console Performance Report in Depth: Position, CTR, Regex Filters, Branded Queries and Query Groups

In the Performance report, position is the average of the topmost result per query, and because the property and page views aggregate differently, CTR differs between the two tabs. We cover anonymized queries, the 1,000-row and 16-month limits, the Turkish character trap in RE2 regexes, the branded filter, query groups, annotations and C# code that finds low-CTR queries against the site's own CTR.

Version note: The code in this post was built and run with .NET 10 and Go 1.27. This post is based on the official documentation: the Search Console Help pages on the Performance report and the Search Central blog announcements, as of 15 November 2026.

Guess First

On the blog of Bookstore, the online bookstore used as the shared example across this site's series, you rewrote the title of the "EF Core migration guide" post. Four weeks later Search Console shows the post's average position going from 8 to 5, so it looks better; but clicks are almost unchanged. The same week Ayşe from the team notices that in the Performance report the CTR on the Queries tab and the CTR on the Pages tab differ for the same date range. Why might the position gain not have turned into traffic? Is one of the two CTRs wrong?

How Are the Metrics Counted?

To read the report correctly, you first need the definitions from Google's help page. An impression is counted when a link appears on the results page the user is currently looking at; it does not have to be scrolled into view. Results inside carousels and expandable sections count only once they become visible. A click is a click that takes the user out of Google; returning to the same result and clicking the same link again is one click. Position is the rank of the site's topmost result for each query, and the report shows the average of those ranks. An AI Overview takes a single position, and all links inside it share that position. In AI Mode a follow-up question counts as a new query.

On 16 June 2025 Google announced that AI Mode data counts toward the Performance report totals; AI Overviews and AI Mode data sit inside the "Web" search type, not separately. Since June 2026 there is also a separate generative AI report that shows impressions only, available on all sites since 31 August 2026; its measurement side was covered in the GEO post "Measuring GEO".

The answer to the second opening question is aggregation. The chart and the Queries, Countries, Devices and Dates tabs aggregate by property: if two pages of the site appear for the same query, that is one impression and the position is the topmost result. The Pages and Search appearance tabs aggregate by page: each URL counts separately. If two Bookstore posts appear at positions 3 and 7 for "ef core migration", the property view sees one impression at position 3; the page view sees two impressions, one at position 3 and one at position 7. The same click is divided by different denominators in the two views, so CTR differs. Both are right.

The relationship between position and traffic also comes from this average. Average position is the mean over a mix of queries: if the post starts appearing at position 40 for a new query, the average gets worse while traffic may have grown; conversely, if impressions for low-ranking queries disappear, the average improves without a single new click. Google's traffic-drop guide also advises giving position less weight than impressions and clicks.

Anonymized Queries, Row Limits and Retention

Search Console anonymizes some queries for privacy. They are included in the chart total but are not in the query table; when you apply a query filter such as "queries containing", they drop out of the total as well. So the total can fall the moment you add a query filter; that is not a bug. The table shows only the most important rows and is limited to 1,000 rows; for more you need the API or the bulk export to BigQuery, which is the topic of the third post in this series.

Other limits: data is kept for 16 months; all dates are in Pacific Time (only the 24-hour view uses the browser's time); the newest days can be preliminary and show as a dotted line on the chart. There is also a record that matters for year-over-year comparisons: according to Google's Data anomalies page, a logging error prevented accurate reporting of impressions between 13 May 2025 and 27 April 2026. Clicks were not affected; impressions, CTR and average position were. When you compare autumn 2026 with autumn 2025, part of the difference in impressions and CTR may come from this correction.

Regex Filters: RE2 and the Turkish Character Trap

The "Custom (regex)" option in the Queries and Pages filters uses RE2 syntax. The rules from the help page: matching is partial by default, so without ^ and $ the expression matches anywhere in the text; it is case-insensitive, and you put (?-i) at the start if you want case-sensitive matching; you separate alternatives with | and wrap them in parentheses; to exclude, there is a "Doesn't match regex" option. According to RE2's own documentation, lookahead, lookbehind and backreferences are not supported.

For Turkish queries the real trap is \b. In RE2, \b recognizes only ASCII word boundaries; letters such as ö, ı and ş are not word characters. I tried this with Go, because Go's regexp package uses RE2 syntax. The small tool puts an x next to every matching query:

// re2check tries a Search Console regex against sample queries.
// Go's regexp package uses the RE2 syntax, like Search Console.
package main

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
)

func main() {
	if len(os.Args) != 3 {
		fmt.Fprintln(os.Stderr, "usage: re2check <pattern> <queries.txt>")
		os.Exit(2)
	}
	// Search Console matches case-insensitively by default
	re, err := regexp.Compile("(?i)" + os.Args[1])
	if err != nil {
		fmt.Println("invalid RE2 pattern:", err)
		os.Exit(1)
	}
	f, err := os.Open(os.Args[2])
	if err != nil {
		panic(err)
	}
	defer f.Close()
	s := bufio.NewScanner(f)
	for s.Scan() {
		mark := " "
		if re.MatchString(s.Text()) {
			mark = "x"
		}
		fmt.Printf("[%s] %s\n", mark, s.Text())
	}
}

The output with the eight sample queries in data/queries-sample.txt (the queries are synthetic):

$ cd tools/re2check
$ go run . '\b(örnek|örneği)\b' ../../data/queries-sample.txt
[ ] ef core migration nasıl geri alınır
[ ] dependency injection nedir
[ ] nasil yapilir ef core migration
[ ] ef core migration örneği
[ ] dapper örnek proje
[ ] what is dependency injection
[ ] how to rollback ef core migration
[ ] cs8618 non-nullable property
$ go run . '(^|\s)(örnek|örneği)(\s|$)' ../../data/queries-sample.txt
[ ] ef core migration nasıl geri alınır
[ ] dependency injection nedir
[ ] nasil yapilir ef core migration
[x] ef core migration örneği
[x] dapper örnek proje
[ ] what is dependency injection
[ ] how to rollback ef core migration
[ ] cs8618 non-nullable property
$ go run . '(?=nasıl)' ../../data/queries-sample.txt
invalid RE2 pattern: error parsing regexp: invalid or unsupported Perl syntax: `(?=`
exit status 1

\b(örnek|örneği)\b matches no line at all, because for RE2 there is no word boundary between a space and ö. The second expression, written with whitespace and start/end of line, finds both lines. The lookahead is rejected outright. If you try the expression in .NET the result differs, because .NET's \b is Unicode-aware; we recorded that in a test:

    [Fact]
    public void DotNet_regex_word_boundary_is_unicode_unlike_re2()
    {
        // .NET \b is Unicode-aware; RE2 (Search Console) treats only ASCII as word characters.
        Assert.Matches(new Regex(@"\b(örnek|örneği)\b"), "dapper örnek proje");
    }

The practical rule: try Search Console regexes in an RE2-compatible engine, not in PCRE or .NET. A few expressions that work for a technical blog: for Turkish question patterns (^|\s)(nedir|nasıl|nasil|neden)(\s|$), for English ^(how|what|why)\s, and to isolate English pages, ^https://www\.bookstore\.example/en/ in the Pages filter. Do not forget searches typed without Turkish characters (nasil).

Comparison Mode

Comparison puts two values of any dimension side by side: two date ranges, two countries, two devices, two pages or two search types. The table gets a Difference column, and sorting by it finds the queries or pages that changed most. Only one comparison can be active at a time. The help page's advice for date comparisons matters: choosing weekly or monthly granularity removes the day-of-week effect, so you are not comparing a Monday with a Saturday. If a value is very rare in one group, you see ~ in that cell; filtering on that value brings data for both groups. Doing a before/after comparison honestly is covered in detail in the last post of the series.

Branded Query Filter, Query Groups and Annotations

On 20 November 2025 Google added the branded and non-branded query filter to the Performance report; per the 11 March 2026 update it is available on all eligible sites. Branded queries are those containing your brand, its variants, misspellings and products. The classification is done not by keyword matching but by an AI-assisted system at Google, and the announcement says plainly that some queries may be misclassified. The filter exists only on top-level properties, not on URL-path or subdomain properties; it needs enough query and impression volume; its data starts in March 2025. For the question "is SEO bringing us new readers?", the non-branded view is the more meaningful one: a search by someone who already knows you does not show a new reader.

Query groups came to the Insights report on 27 October 2025. They group similar queries, misspellings, rephrasings and languages with AI and show the groups with the most clicks, the rising ones and the falling ones. Groups can change over time, do not affect ranking and appear only on properties with a large query volume; sites with few queries keep a plain list. On a small site such as the Bookstore blog, not seeing this card is normal.

Since 17 November 2025 you can add your own annotations to the chart: right-click the chart, pick a date and add a note of up to 120 characters. There is a limit of 200 annotations per property, annotations older than 500 days are deleted, and because the note is shared with every user of the property, it should contain no personal information. How to use annotations in impact measurement is covered in the last post.

Scenario: High Impressions, Low CTR

The most practical use of the report is finding queries that are seen a lot but clicked little. The help page also mentions low CTR as a sign that the title or description does not match the content. But "low" compared to what? Industry-average CTR curves know nothing about the site, the query type or the layout of the results page. A more honest reference is the site's own CTR in the same position range. The code below does that; it drops rows with too little data up front:

public static class CtrOpportunities
{
    public static string BucketOf(double position) => position switch
    {
        < 3.5 => "1-3",
        < 10.5 => "4-10",
        < 20.5 => "11-20",
        _ => "21+",
    };

    // Compares every row with the site's own CTR for the same position bucket,
    // not with an industry curve.
    public static CtrOpportunityResult Find(IEnumerable<QueryPageRow> rows, int minImpressions = 100, double ratio = 0.5)
    {
        List<QueryPageRow> enough = [];
        int tooLittle = 0;
        foreach (QueryPageRow row in rows)
        {
            if (row.Impressions >= minImpressions)
            {
                enough.Add(row);
            }
            else
            {
                tooLittle++;
            }
        }

        Dictionary<string, double> bucketCtr = enough
            .GroupBy(r => BucketOf(r.Position))
            .ToDictionary(g => g.Key, g => (double)g.Sum(r => r.Clicks) / g.Sum(r => r.Impressions));

        List<CtrOpportunity> opportunities = enough
            .Where(r => r.Position < 10.5)
            .Select(r => (Row: r, Bucket: BucketOf(r.Position)))
            .Where(x => x.Row.Ctr < bucketCtr[x.Bucket] * ratio)
            .Select(x => new CtrOpportunity(x.Row, x.Bucket, bucketCtr[x.Bucket],
                x.Row.Impressions * (bucketCtr[x.Bucket] - x.Row.Ctr)))
            .OrderByDescending(o => o.MissedClicks)
            .ToList();

        return new CtrOpportunityResult(bucketCtr, opportunities, tooLittle);
    }
}

The input is data/gsc-pages-queries.csv: a synthetic 22-row export by page and query. The numbers were made up for the Bookstore blog; they are not data from a real site:

$ dotnet run --project src/Bookstore.Analytics.Cli -- opportunities data/gsc-pages-queries.csv
site CTR by position bucket (rows with >= 100 impressions):
  1-3     8.0 %
  4-10    3.1 %
  11-20   0.5 %
rows skipped (< 100 impressions): 3

query                               impr     ctr   pos  bucket ctr  missed
ef core migration nasıl yapılır     2100   1.9 %   3.2       8.0 %     128
dependency injection                2600   0.3 %   8.9       3.1 %      72
c# async await                      1300   0.8 %   9.6       3.1 %      30
dapper nedir                         700   0.9 %   7.2       3.1 %      16

The first row is a typical finding: for "ef core migration nasıl yapılır" the post averages position 3.2, but its CTR is a quarter of the site's average for positions 1-3. That suggests the title does not answer the "how to" intent. "dependency injection" is a different case: a short, general query, and the results page may hold other kinds of content too; here look at search intent before the title. How Google picks titles and descriptions is covered in the SEO Fundamentals post "Appearing in Results", and reading intent in the Content SEO series. The missed column is not a forecast but a priority order: the rough size of the extra clicks if the row reached the site's average for its range.

Production Notes

Note which view you are in. If you take one query's CTR from the Queries tab and a page's CTR from the Pages tab and put them in the same table, you mix two aggregations.

Know that the total changes when the filter changes. A query or page filter leaves out anonymized queries and truncated rows; "the filtered total" and "the chart total" are not the same thing.

Keep your regexes in a file. Write down the expressions you use often and what they catch in a README; a small mistake while typing the same expression by hand every month changes the report silently.

If you want history beyond 16 months, start today. Search Console keeps deleting old data; you cannot fill the archive backwards later.

Common Pitfalls

Reading CTR on low-volume queries. On a query with 20 impressions, 1 click is 5 percent and 0 clicks is 0 percent; the difference can be pure chance. A CTR list without a threshold turns random variation into findings.

Taking a position gain for a traffic gain. Average position depends on the query mix; decide on clicks and impressions.

Forgetting data corrections in year-over-year comparisons. Records such as the 2025-2026 impression logging error are on the Data anomalies page; check it before comparing.

Treating the branded filter as an exact split. The classification is done with AI and can be wrong; before an important decision, check by hand which group your largest queries fall into.

A Small Experiment

★☆☆ For your own site, write down the total CTR on the Queries and Pages tabs for the same date range. How big is the difference? Find a query where the site appears with more than one page.

★★☆ With the re2check tool, write an expression that catches Turkish question patterns: queries containing "nasıl", "nasil", "nedir" and "ne demek", but not queries such as "nedirler".

★★★ Add a per-page mode to CtrOpportunities: if several queries of the same page are in the list, show the page once with the total missed value. Write its test.

We have seen the limits of the interface: 1,000 rows, 16 months, filters set by hand. In the next post we get past those limits with the Search Console API and the BigQuery bulk export, and automate a weekly SEO report in C#.