Mert Özen Further With Every Line
SEO & GEO Content SEO — #02

Keyword Research: Finding Topics and Queries for a Technical Blog, from Search Console to Keyword Planner

Mert Özen Nov 14, 2026 13 min 1 views
Keyword Research: Finding Topics and Queries for a Technical Blog, from Search Console to Keyword Planner

For a technical blog the most reliable keyword source is your own Search Console data; autocomplete, Google Trends and Keyword Planner give phrasings, comparisons and estimates, not real volume. We cover each source's limits, how Turkish developers search with English jargon and error messages, and building a TR/EN keyword map from bulk export data with .NET 10.

Version note: The code in this post was built and run with .NET 10; the coverage of Search Console, Google Trends and Keyword Planner reflects the state of these tools in November 2026.

Guess First

Two rows catch your eye in the Bookstore blog's Search Console data. The query "dependency injection" got 1,180 impressions in the last three months, but its average position is 12.7 and clicks are few. The query "cs8618" got 900 impressions, its position is 2.1 and most of its clicks go to the post. A teammate looks at Keyword Planner and says: "dependency injection gets thousands of searches a month, cs8618 almost none; let's write the new post about dependency injection." Is the data you have enough to make this decision? Whose searches does the Keyword Planner volume count, Turkish or English? And can an error code with 900 impressions really be a query that "almost nobody searches for"?

From a Seed Topic to a Query Family

In SEO, keyword research is the step of finding the words readers use to search for a topic. The starting point is not a single word but a seed topic, for example "EF Core migration" for EF Core. Around that topic there is a family of queries written with different intents: "ef core migration nedir", "ef core migration nasıl yapılır", "dotnet ef migrations add", "ef core migration geri alma", "remove-migration hatası". Should every query in the family go to the same post or to separate posts? That question is the subject of the third and fourth posts of the series; in this post we first learn to extract the family and how reliable each source is.

Google's SEO Starter Guide draws a realistic frame here: people search for a topic with different words depending on how familiar they are with it; thinking about these variations helps, but you do not need to cover every possible phrasing, because Google's language matching can connect a page to queries that do not use exactly the same words. So the goal is not to fill a keyword list but to see the questions readers actually ask.

Source 1: Your Own Search Console Data

For a technical blog the most reliable source is the real data Google gives you about your own site: the Search Console Performance report. There is no estimation here; you get how many times your pages were shown for which queries (impressions), how many times they were clicked (clicks), CTR and your average position. We introduced the report in the Search Console post of the SEO Fundamentals series; here we look at the part needed for keyword decisions.

Knowing the limits of this data is the first condition for not making the wrong decision:

  • Only queries you appear for are there. Queries for a topic you have never written about do not show up; Search Console is strong for improving existing content and weak for finding new topics.
  • Anonymized queries are missing from the table. For privacy, Google leaves out queries that were not issued by more than a few dozen users over a two-to-three-month period. These queries count towards the chart totals but not towards the rows, so summing the table rows will not give you the chart number. On technical blogs with many long, rare queries the gap can be large.
  • The UI and its export are limited to 1,000 rows. The Search Analytics API returns up to 50,000 rows per site, per day, per search type. For more than that and for history older than 16 months you need to set up a bulk export to BigQuery.
  • Query and page together. In the UI the Queries and Pages tabs are separate tables; you can ask "which pages does this query go to?" only one filter at a time. In the API, or in the searchdata_url_impression table of the bulk export, query and URL are on the same row.

Separating brand queries matters too. Google added the branded and non-branded query filter to Search Console in November 2025, and since March 2026 it is available on all sites with enough query volume. The classification is done not by a regex but by an AI-assisted system at Google, so some queries may be misclassified. A search like "bookstore blog ef core" from someone who already knows you tells you nothing about whether you are reaching new readers.

The suggestions that appear when you type "ef core migration" into the search box (autocomplete), the "People also ask" box on the results page and the related searches at the bottom of the page are the fastest way to see how readers phrase the question. According to Google's own explanation, autocomplete predictions come from real searches; language, location and trending interest are taken into account, and for signed-in users past searches can play a role too. But Google says plainly that this is not a volume indicator: autocomplete does not simply list the most frequent searches and should not be compared with Google Trends.

Use these sources to collect question phrasings: "ef core migration geri alma", "how do I delete a migration from the database", "why does remove-migration not work" are direct material for a post's headings and frequently asked questions. Because results can be personalised, check them in a private window and in the target language.

Google Trends does not give absolute search counts. It normalises each data point against the total search volume for that place and time, then maps the result onto a scale where the highest point in your selected range is 100; it also works on a sample of all searches. Low-volume terms can show as 0, and small spikes in low-interest queries can be statistical noise.

So the best use of Trends for a technical blog is comparison: putting two phrasings side by side in the same time range and the same region. Selecting Turkey as the region and comparing "dependency injection nedir" with "what is dependency injection" gives an idea of which language developers in Turkey use for this topic. Seasonality and rising topics also show up here, such as when searches for a version name rise after a new .NET release. But 100 in Trends does not mean "searched a lot"; it means "the highest point in this comparison".

Source 4: Keyword Planner and Third-Party Tools

Keyword Planner is a Google Ads tool, designed for advertisers. To use it you have to open a Google Ads account and complete billing setup. Its "average monthly searches" is an estimate; for accounts with low spend the number often appears as a range such as "1K–10K" rather than an exact value, and Google does not disclose the threshold. The "Competition" column describes how many advertisers bid on the keyword, not the competition in organic rankings.

These limits explain the Bookstore discussion. "dependency injection" is a high-volume generic term, searched worldwide and mostly in English, competing with many strong documentation pages. "cs8618", on the other hand, is a query advertisers do not care about, which Planner shows as low or as a range, but which a developer types exactly the moment they hit the compiler warning. The 900 impressions in Search Console are a more reliable signal than Planner's estimate.

The volume and "keyword difficulty" values of third-party SEO tools are estimates too. Google's guidance on third-party tools and services, published in June 2026, says so explicitly: these tools have no access to Google's internal ranking data, and what they show are their own estimates. The recommended place for data directly from Google is Search Console.

The Reality of Turkish Technical Content

A technical blog that writes in Turkish cannot copy its keyword research from an English guide, because Turkish developers search in a mixed way:

Jargon stays in English. Readers type "dependency injection nedir" rather than a Turkish translation of the term. The subject of the query is English, the question word is Turkish. Very often they type only "dependency injection"; the language of that query is unknown.

Error messages are pasted as they are. Because compiler and runtime messages are in English, Turkish readers query in English too: "non-nullable property must contain a non-null value when exiting constructor". These queries are long, rare and carry a very clear intent; when they land on the right page their click-through rate is high.

TR and EN versions need separate keyword maps. Every post on mertozen.com has a TR and an EN version, but the target queries of the EN version are not a translation of the TR ones. While the TR page competes for "ef core migration nasıl yapılır", the EN page competes for a global and much more competitive query such as "ef core migrations". That is why the map has to be kept per language.

Building the Keyword Map in Code

Instead of keeping a table by hand, let's write a small console app that produces the keyword map from an export that gives query and URL together. The input is the searchdata_url_impression table from the bulk export, aggregated by URL and query. The rows below are a sample export prepared for the Bookstore blog; they are not a real site's numbers, they are there to show the method.

url,query,impressions,clicks,sum_position
/blog/ef-core-migration-rehberi,ef core migration,420,31,2184
/blog/ef-core-migration-rehberi,ef core migration nasıl yapılır,310,38,961
/blog/ef-core-migration-rehberi,ef core add-migration,150,9,1110
/blog/ef-core-migration-rehberi,ef core migration geri alma,260,7,2288
/blog/ef-core-migration-geri-alma,ef core migration geri alma,240,12,1560

sum_position is the sum of zero-based positions; we compute the average position with the formula from Google's bulk export documentation: divide the sum by impressions and add 1.

namespace Bookstore.ContentSeo;

// The searchdata_url_impression table from the Search Console bulk export, aggregated by url + query
public sealed record SearchRow(string Url, string Query, int Impressions, int Clicks, long SumPosition)
{
    // sum_position is zero-based; average position = sum / impressions + 1
    public double AvgPosition => (double)SumPosition / Impressions + 1;

    public static SearchRow Parse(string line)
    {
        string[] f = line.Split(',');
        return new SearchRow(f[0], f[1], int.Parse(f[2]), int.Parse(f[3]), long.Parse(f[4]));
    }
}

A simple rule is enough to tell the language of a query: Turkish characters or a Turkish question word mean TR, an English question word means EN, jargon only means "neutral". The Turkish word list has no Turkish characters, because some readers search for "nasil yapilir" without them.

namespace Bookstore.ContentSeo;

public static class QueryLanguage
{
    private static readonly string[] TurkishWords =
        ["nedir", "nasil", "yapilir", "hatasi", "uyarisi", "farki", "geri", "alma", "yenilikleri", "toplu", "mi", "mu", "duzeltilir"];
    private static readonly string[] EnglishWords =
        ["what", "is", "how", "to", "new", "features", "must", "when", "bulk", "fix", "error"];

    // "tr", "en", or "neutral" for queries that contain only jargon
    public static string Detect(string query)
    {
        if (query.Any(c => "çğıöşü".Contains(c)))
        {
            return "tr";
        }
        string[] tokens = query.Split(' ');
        if (tokens.Any(TurkishWords.Contains))
        {
            return "tr";
        }
        return tokens.Any(EnglishWords.Contains) ? "en" : "neutral";
    }

    public static bool Mismatch(string lang, string url) =>
        (lang == "tr" && url.StartsWith("/en/")) || (lang == "en" && !url.StartsWith("/en/"));
}

The main program splits queries into families by seed topic, finds the total impressions and the URL with the most impressions for each query, and flags queries whose language does not match the URL's language or that go to more than one URL.

using System.Globalization;
using Bookstore.ContentSeo;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string path = args.Length > 0 ? args[0] : "gsc-url-query.csv";
List<SearchRow> rows = [.. (await File.ReadAllLinesAsync(path)).Skip(1).Select(SearchRow.Parse)];

// Seed topic -> terms that catch the queries of that family (first match wins)
(string Family, string[] Terms)[] families =
[
    ("ef-core-migration", ["migration"]),
    ("dependency-injection", ["dependency injection", "addscoped", "scoped singleton"]),
    ("nullable-cs8618", ["cs8618", "non-nullable"]),
    ("ef-core-dapper", ["dapper"]),
    ("ef-core-10", ["ef core 10"]),
];

string FamilyOf(string query) =>
    families.FirstOrDefault(f => f.Terms.Any(query.Contains)).Family ?? "(unassigned)";

List<KeywordRow> map = [.. rows
    .GroupBy(r => r.Query)
    .Select(g => new KeywordRow(
        FamilyOf(g.Key), g.Key, QueryLanguage.Detect(g.Key),
        g.Sum(r => r.Impressions), g.Sum(r => r.Clicks),
        Top: g.MaxBy(r => r.Impressions)!, Urls: g.Count()))
    .OrderBy(x => x.Family).ThenBy(x => x.Lang).ThenByDescending(x => x.Impressions)];

Console.WriteLine($"{"family",-21}{"lang",-8}{"query",-38}{"impr",6}{"pos",6}  target url");
foreach (KeywordRow m in map)
{
    string query = m.Query.Length > 36 ? m.Query[..33] + "..." : m.Query;
    string flags = (m.Urls > 1 ? $" [{m.Urls} urls]" : "")
        + (QueryLanguage.Mismatch(m.Lang, m.Top.Url) ? " [lang mismatch]" : "");
    Console.WriteLine($"{m.Family,-21}{m.Lang,-8}{query,-38}{m.Impressions,6}{m.Top.AvgPosition,6:F1}  {m.Top.Url}{flags}");
}

List<KeywordRow> longTail = [.. map.Where(m => m.Query.Split(' ').Length >= 4)];
Console.WriteLine($"\nqueries: {map.Count}, long-tail (4+ words): {longTail.Count}, " +
    $"long-tail share of clicks: {longTail.Sum(m => m.Clicks) * 100.0 / map.Sum(m => m.Clicks):F0}%");

record KeywordRow(string Family, string Query, string Lang, int Impressions, int Clicks, SearchRow Top, int Urls);

Output of dotnet run:

family               lang    query                                   impr   pos  target url
(unassigned)         neutral bookstore blog ef core                    12   1.0  /blog/ef-core-migration-rehberi
dependency-injection en      what is dependency injection             450  17.9  /en/blog/what-is-dependency-injection
dependency-injection neutral dependency injection                    1180  12.7  /blog/dependency-injection-nedir [2 urls]
dependency-injection neutral addscoped vs addsingleton                310   8.8  /blog/aspnet-core-di-lifetime
dependency-injection neutral dependency injection lifetime            300  10.4  /blog/dependency-injection-nedir [2 urls]
dependency-injection tr      dependency injection nedir               520   3.3  /blog/dependency-injection-nedir
dependency-injection tr      scoped singleton transient farkı         230   2.9  /blog/aspnet-core-di-lifetime
ef-core-10           en      ef core 10 new features                   95  21.0  /blog/ef-core-10-yenilikleri [lang mismatch]
ef-core-10           tr      ef core 10 yenilikleri                   180   2.4  /blog/ef-core-10-yenilikleri
ef-core-dapper       en      dapper bulk insert                       200  13.2  /blog/dapper-ile-toplu-insert [lang mismatch]
ef-core-dapper       neutral ef core vs dapper                        340   6.7  /blog/ef-core-vs-dapper
ef-core-dapper       tr      dapper mı ef core mu                     110   2.8  /blog/ef-core-vs-dapper
ef-core-dapper       tr      dapper toplu insert                       45   1.9  /blog/dapper-ile-toplu-insert
ef-core-migration    neutral ef core migration                       1060  14.6  /en/blog/ef-core-migrations-guide [2 urls]
ef-core-migration    neutral ef core migrations                       980  11.3  /en/blog/ef-core-migrations-guide
ef-core-migration    neutral dotnet ef migrations add                 280   9.1  /en/blog/ef-core-migrations-guide [2 urls]
ef-core-migration    neutral ef core add-migration                    150   8.4  /blog/ef-core-migration-rehberi
ef-core-migration    tr      ef core migration geri alma              500   9.8  /blog/ef-core-migration-rehberi [2 urls]
ef-core-migration    tr      ef core migration nasıl yapılır          310   4.1  /blog/ef-core-migration-rehberi
ef-core-migration    tr      ef core migration nedir                  130   5.0  /blog/ef-core-migration-rehberi
ef-core-migration    tr      remove-migration hatası                   90   3.2  /blog/ef-core-migration-geri-alma
nullable-cs8618      en      non-nullable property must contai...     370   1.6  /blog/cs8618-nullable-uyarisi [lang mismatch]
nullable-cs8618      neutral cs8618                                   900   2.1  /blog/cs8618-nullable-uyarisi [2 urls]
nullable-cs8618      tr      cs8618 uyarısı nasıl düzeltilir           60   1.4  /blog/cs8618-nullable-uyarisi

queries: 24, long-tail (4+ words): 13, long-tail share of clicks: 48%

Reading the output

More than half of the rows are of two kinds: queries with a Turkish question word and jargon queries whose language is unclear. For the "neutral" query "ef core migration" the EN page gets the most impressions at position 14.6; the TR page appears for the same query. That is not a conflict but two language versions; still, it is worth checking why the TR page lags behind for Turkish readers.

Three rows carry [lang mismatch], and each tells a different story. "ef core 10 new features" lands on the TR page at position 21: English readers are looking for a page on this topic but there is no EN version, so the content is missing. "dapper bulk insert" is similar. "non-nullable property must contain..." lands on the TR page at position 1.6; this is the Turkish reader who pasted the English error message. There is no problem here; on the contrary, the TR page is the right answer for this query, and looking at the query with the country dimension in Search Console is enough to confirm it.

Two rows carry [2 urls] and point to two pages in the same language: "ef core migration geri alma" and "dependency injection lifetime". These are the cannibalization candidates for the fourth post of the series.

The last line shows the value of the long tail: queries of four or more words make up more than half of the queries and 48% of clicks. Error messages and "how to" questions are small one by one, but together they are most of the blog's traffic.

The map we extracted by hand from this output shows, for each family, which page is the target in which language and what the next step is:

Query familyIntentTR targetEN targetNext step
ef core migrationDefinition and step by step/blog/ef-core-migration-rehberi/en/blog/ef-core-migrations-guideChoose the owner of the rollback query
ef core 10Release notes/blog/ef-core-10-yenilikleriNoneWrite the EN version
dependency injectionDefinition/blog/dependency-injection-nedir/en/blog/what-is-dependency-injectionLook into the EN page's 17.9 position
cs8618Fixing an error/blog/cs8618-nullable-uyarisi/en/blog/cs8618-non-nullable-property-warningKeep; new posts for similar warnings
dapperComparison and step by step/blog/ef-core-vs-dapper, /blog/dapper-ile-toplu-insertNoneConsider an EN bulk insert post

Back to the opening question: according to the data, Bookstore's next post is not a generic "dependency injection" post. The cs8618 page ranks second for a query where the reader arrives with an error message and the page answers it; posts for other warnings of the same kind (such as CS8602 and CS8604) are a direction backed by evidence.

In Production

Pull the data regularly. Downloading 1,000 rows from the UI once a month misses the long tail of queries. Daily pulls with the Search Analytics API or the BigQuery bulk export are the way past both the row limit and the 16-month history limit. Once the bulk export is set up, a single SQL query produces the input file used in this post.

Account for anonymized queries. When you apply a query filter, anonymized queries drop out of the calculation entirely; ratios such as "EF Core-related queries are this share of total clicks" therefore come out lower than they really are.

Keep the seed list outside the code. In this example the families live in code; in real use, keeping them in a JSON file or a database lets an editor add a new family without changing code.

Do not read the average position on its own. A page's average position across many queries is an average of very different queries; look at it per query when making decisions.

Common Pitfalls

Choosing topics by volume. The highest-volume keyword in Keyword Planner is often the most generic, the most competitive and the one with the vaguest reader intent. For a technical blog the direction backed by evidence is the reader's real problem.

Targeting the same keyword with two posts. In the keyword map every query family should have a single owner per language. Otherwise two pages get in each other's way for the same query; the [2 urls] rows in the output are the first sign of this and the subject of the fourth post of the series.

Stuffing keywords into the text. Keyword stuffing is a spam policy violation: filling a page with the same words or groups of words in an unnatural way. The SEO Starter Guide also says that repeating the same word excessively wears readers out. The purpose of the keyword map is not to add words to the text but to decide which page answers which question.

Reading 100 in Trends as volume. 100 is the highest point in the comparison; the 100 of two different comparisons is not the same thing.

A Small Experiment

★☆☆ In your own site's Search Console, filter the Queries table to queries of four or more words (with a regex filter). How many of them are an error message or a "how to" question?

★★☆ Add a second rule to QueryLanguage that uses the URL's language for "neutral" queries and compare the result with the country dimension: how many of the "neutral" queries from Turkey go to the TR page?

★★★ Write a job that pulls the last 90 days from the Search Analytics API with the query and page dimensions, paging through the results, and produces the input file format used in this post. Have it read the seed list from a JSON file.

We filled the "Intent" column of the keyword map by intuition. In the next post we make that column systematic: reading the search intent behind a query, taking clues from the results page and choosing the right content format accordingly.