Skip to content

CodeQL: How GitHub Code Analysis Works

Lesson 2 of 8Advanced16 min readGit Security & DevSecOps · Code & Dependency SecurityVerified: CodeQL supported languages and build modes, github/codeql-action v4, September 2026

Most static analysers pattern-match on source text. CodeQL does something structurally different: it compiles your code into a relational database describing the program — its syntax, its types, its control flow, its data flow — and then runs queries against that database.

The consequence is the thing worth understanding. A grep-based tool can find a dangerous function call. CodeQL can answer “is there any path by which data an attacker controls reaches this function call?” — which is a question about the program, not about the text.

Two phases. Create a database from the code, then run queries against it.

A query language. QL, a declarative logic language. You do not need to write it to use CodeQL — GitHub ships thousands of maintained queries.

Configuration is per language, and the part that most often goes wrong is the build mode for compiled languages.

The output is SARIF, which lands in code scanning like any other analyser’s results.

The first phase extracts a database. For an interpreted language this means parsing; for a compiled one it means observing the build, so the extractor sees exactly what the compiler saw.

The database contains a relational representation of the program: every expression, every type, every call, the control flow graph, and the data flow relationships derived from them.

This is why analysis is not instant. Building a database for a large codebase takes minutes, and it is the reason CodeQL can answer questions that require following a value through several functions, across files, into a library and back.

It is also why incomplete extraction is the most consequential failure mode. If the extractor did not see part of your code, that part is not in the database, no query examines it, and the result is zero findings — which is indistinguishable from clean code.

Verified against the CodeQL documentation, September 2026. The identifier is what you put in a workflow’s languages: field.

LanguageIdentifierNotes
C/C++c-cppC89 through C23; C++98 through C++23
C#csharpUp to C# 14 and .NET 10
GogoUp to Go 1.27
Java / Kotlinjava-kotlinJava 7–26; Kotlin 1.8.0–2.4.1
JavaScript / TypeScriptjavascript-typescriptECMAScript 2022 or lower; TypeScript 2.6–7.0
Pythonpython2.7 and 3.5 through 3.14
RubyrubyUp to 3.3
RustrustEditions 2021 and 2024
Swiftswift5.4–6.3; requires macOS runners
GitHub ActionsactionsAnalyses workflow and action metadata files

Two entries deserve attention.

actions analyses your workflow files. Given that a workflow change is a change to what runs with your repository’s credentials, this is one of the highest-value languages to enable and one of the least enabled. It finds the injection patterns described in Workflow security.

swift requires macOS runners, which are more expensive. Budget for it rather than discovering it.

The languages not listed are not analysed. PHP and Scala are the two most commonly assumed to be supported and are not.

For compiled languages, how the database gets built is the setting that most often produces silently wrong results.

ModeWhat happens
noneThe database is created without running a build
autobuildCodeQL detects and runs the build system
manualYou supply the build commands in the workflow

none is the convenient option and it has a documented cost. GitHub’s own guidance is explicit that creating a database without a build “may produce less accurate results than using autobuild or manual build steps if the build scripts cannot be queried for dependency information, and dependency guesses are inaccurate”. Generated code — anything the build produces rather than the repository containing — is also missed.

autobuild works for conventional projects. It fails on unusual ones, and its failure is a job failure, which is at least visible.

manual is what you use when autobuild cannot work:

- uses: github/codeql-action/init@v4
with:
languages: java-kotlin
build-mode: manual
- name: Build
run: ./gradlew --no-daemon assemble
- uses: github/codeql-action/analyze@v4

The build must actually compile the code you want analysed. A build that skips modules, or that resolves everything from a cache without compiling, produces a database missing exactly the code you were trying to check.

Which queries run against the database determines what you find and how much you have to read.

SuiteContents
defaultHigh-precision security queries
security-extendedMore security queries, including lower-precision ones
security-and-qualityExtended, plus maintainability and correctness queries
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
queries: security-extended

The progression to follow is default first, security-extended once the default findings are being worked, and security-and-quality only if you want code health in the same interface as security — which dilutes the security signal and is usually the wrong trade for a security queue.

You can use CodeQL productively without writing QL. This section exists so the findings make sense rather than to teach the language.

A query has three clauses:

from /* variable declarations */
where /* logical formulas */
select /* expressions */

The documentation’s own introductory example, which touches no program at all:

from int x, int y
where x = 6 and y = 7
select x * y

Analysing a real program means importing that language’s library, which supplies the classes describing the program:

import python

The security queries are built on taint tracking: a configuration declaring what counts as a source of untrusted data, what counts as a dangerous sink, and which operations sanitise a value in between. The engine then finds paths from any source to any sink that are not sanitised.

That structure explains both the strength and the failure modes. A finding is a path, which is why the alert shows you the flow rather than a line. A false positive usually means a sanitiser the query did not model. A false negative usually means a source or a sink nobody declared.

Custom queries are worth writing when your codebase has a pattern the standard queries do not model — an internal API that must always be called with a permission check, a deprecated function that must not be reintroduced.

They are distributed as query packs, versioned and published, and referenced from a configuration:

.github/codeql/codeql-config.yml
name: Custom configuration
packs:
- your-org/security-queries@1.2.3
paths-ignore:
- "**/test/**"
- "**/vendor/**"
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml

Two things about paths-ignore worth being deliberate about. It reduces noise from generated and vendored code, and it also means nothing in those paths is analysed — including a real vulnerability in vendored code you ship. Exclude for noise, knowingly, not by default.

Pin the pack version. A floating reference means your analysis changes without a change on your side.

Everything above runs in Actions. The same engine is available as a command-line tool, and there are three situations where running it locally is worth the setup.

Developing a custom query. The edit-run-inspect cycle against a local database takes seconds. The same cycle through a workflow takes minutes and burns Actions minutes.

Reproducing a finding. When an alert looks wrong, running the same query against the same database locally is how you establish whether the query is wrong or your reading of it is.

Security research. Given a database of a codebase, you can ask arbitrary questions of it — “every call to this function where the third argument is not a constant”, “every path from any deserialisation to any file write”. This is a genuinely different activity from reading a scanner’s output, and it is what CodeQL was built for.

The shape of local use:

Terminal window
{/* Build a database from a source tree */}
codeql database create ./db --language=python --source-root=.
{/* Run a query suite against it, producing SARIF */}
codeql database analyze ./db --format=sarif-latest --output=results.sarif

The CLI is included with GitHub Code Security licensing, and available for public repositories. Its version needs to be reasonably current relative to the query packs you run, because queries use library features that ship with the extractors.

A repository containing several languages, or several independently-built projects, is where CodeQL configuration gets genuinely difficult. Three problems recur.

Analysis time multiplies. Five languages means five database builds. Use a matrix so they run in parallel, and consider whether every language needs analysing on every pull request:

strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: go
build-mode: autobuild

Path filters cut both ways. Restricting the workflow to run only when relevant paths change saves time, and it means a pull request touching nothing in those paths produces no analysis — so a required check on that analysis never reports. The resolution is to make the job always run and exit early, so the check reports success rather than not reporting at all. This is the same trap described in Branch protection for security.

A partial build hides code. In a monorepo where autobuild finds one project’s build file and builds only that, the database covers one project and the report covers the repository. This is the build-mode problem at scale, and the symptom is a suspiciously fast analysis.

The check worth doing once: compare the number of files the extractor reported against the number of source files you expect. A large gap is the finding.

Threat. A vulnerability introduced in first-party code reaches production, and an attacker reaches it through a path from input they control.

Attack surface. Every source of untrusted data — request parameters, headers, uploaded files, message payloads, environment values in some deployments — and every sink where a value has consequences.

Impact. From nothing to complete compromise, determined by what the sink does and whether the path is reachable in the deployment. The analysis reports the path; the deployment determines the impact.

Control. Analysis on every pull request, so a defect is caught in the change that introduced it; scheduled analysis so existing code meets new queries; custom queries for patterns specific to your codebase; and a merge gate on alert state.

Verification. Introduce a known taint path and confirm it is flagged. Then verify the negative case: check the extractor’s file counts, and confirm that the languages you believe are being analysed actually appear in the analysis log.

The second half of that verification is the one that matters, because CodeQL’s most dangerous failure is silent. A misconfigured analysis produces a green check and an empty alert list — which is exactly what a healthy repository produces.

Worth stating, because “we already have a linter with security rules” is a common and reasonable question.

Pattern-based analysersCodeQL
Operates onSource text, or a syntax treeA relational database of the program
Typical question“Does this call look dangerous?”“Is there a path from untrusted input to this call?”
Cross-file reasoningLimited or noneYes — flow is followed across functions and files
SpeedSecondsMinutes
False positivesHigher, because context is unavailableLower for security queries, because the path is proven
ExtensibilityRules in a config formatA query language with a program model
CostUsually freeFree on public repositories; licensed on private

The practical position is that they are complementary rather than competing. A fast linter with security rules belongs in the pull request check because it runs in seconds. CodeQL belongs there too where budget allows, and belongs on a schedule regardless.

What a linter cannot do is the thing that matters most: follow a value from an HTTP handler, through three helper functions, into a database call in a different module. That requires the program model, and the program model is what the database is.

The sequence that avoids the two failure modes — an unread backlog, and a silently broken analysis.

  1. Enable default setup on a handful of active repositories. Read what comes back. Do not gate anything yet.

  2. Verify coverage before trusting the results. Check the analysis log: which languages ran, how many files were extracted, and whether any warnings appeared. This step is the one that catches the Kotlin case and the partial-build case.

  3. Triage the initial findings and calibrate. What proportion are real? That number determines how aggressive you can be about gating.

  4. Gate on new alerts only, at high or above, through a ruleset.

  5. Move the backlog into a security campaign with an owner and a deadline, separate from the gate.

  6. Add the scheduled full run at security-extended.

  7. Enable the actions language, which is cheap and covers workflow files.

  8. Write custom queries for the patterns specific to your codebase, once the standard suites are under control.

Step 2 is the one that gets skipped and the one that determines whether any of the rest means anything.

CodeQL alerts differ from most analysers’ in that they carry a path, and reading the path is the triage.

  1. Look at the source. Is it genuinely attacker-controlled? A “user input” that comes from a config file only an administrator edits is a different risk.

  2. Look at the sink. What actually happens with the value.

  3. Walk the intermediate steps. The alert shows each hop. This is where a sanitiser the query did not model turns up, and it is the most common legitimate reason to dismiss.

  4. Decide whether the path is reachable in practice. The analysis proves the path exists in the code. Whether that code path runs in your deployment is a question the analysis cannot answer.

  5. Record the reasoning. The dismissal reason is the only artefact of this work.

Note the asymmetry in step 4. CodeQL is sound about the code and silent about the deployment. A path through a code branch that is disabled by configuration is still a path — and configuration changes.

Business logic. An authorisation check that permits the wrong access is correct code with the wrong specification. No analyser has the specification.

Anything not in the database. Incomplete extraction, excluded paths, unsupported languages, generated code missed by build-mode: none.

Frameworks it does not model. Taint tracking depends on the library modelling knowing that a given framework function is a source or a sink. A new or in-house framework is invisible until somebody models it.

Dependencies. CodeQL analyses your code. Vulnerabilities in packages you depend on are Dependabot and dependency review’s territory.

Runtime and configuration. A perfectly analysed application with a misconfigured deployment is a compromised application.

CodeQL is the slowest check in most pipelines, and the two levers that matter are worth knowing before somebody proposes turning it off.

Run the full analysis on a schedule, and a narrower one on pull requests. A weekly security-extended run over everything, plus a default-suite pull request analysis, gets most of the value at a fraction of the cost.

Analyse languages in a matrix with fail-fast: false. Parallel, and one language’s failure does not discard the others’ results.

What not to do is drop the scheduled run to save time. Push-triggered analysis only ever examines new code; the scheduled run is what re-examines existing code against queries added since — which is how a newly-modelled vulnerability class gets found in code written two years ago.

False positives, and what they usually mean

Section titled “False positives, and what they usually mean”

“CodeQL is noisy” is a common complaint and it is usually a diagnosis of one of four specific things. Distinguishing them is what makes tuning possible.

An unmodelled sanitiser. The most common case. Your code validates or escapes the value using a function the query does not know about, so the path looks unbroken. The finding is technically correct — the query cannot see the sanitiser — and the resolution is either a dismissal with a clear reason, or a custom query extension declaring the function as a sanitiser. The second scales; the first does not.

An unreachable path. The code path exists and cannot execute in practice — a branch behind a feature flag that is permanently off, a code path only used by a deleted entry point. Legitimate to dismiss, and worth noting that “permanently off” is a configuration statement that can change.

A source that is not really untrusted. The query treats a value as attacker-controlled and in your system it is not. A configuration value written only by an administrator, for instance. Reasonable, and worth checking that the assumption holds for every deployment rather than the one you are thinking of.

Test code. Findings in test files where the risk does not apply. Handle this with paths-ignore or a dismissal, and check that the path really is test-only before doing either.

What is not usually happening is the query being wrong about the program. CodeQL’s security queries are heavily used and the analysis is sound about the code it can see. When a finding looks wrong, the gap is nearly always between the code and the deployment, or between the code and a model the query lacks — both of which are facts about your system worth writing down rather than dismissing silently.

build-mode: none on a Java repository containing Kotlin. The Kotlin is not analysed, and only a log warning says so.

A manual build that does not build everything. The database is missing the code you cared about, and the result is a clean report.

Assuming a language is supported. PHP and Scala are the usual cases. Check the list.

Broad paths-ignore. Excluding vendor/ silences noise and also silences real findings in code you ship.

Floating query pack references. Analysis behaviour changes without any change on your side.

No scheduled run. New queries never meet old code.

fail-fast at its default in a language matrix. One build failure discards every language’s results.

Reading the alert title instead of the path. The path is the evidence, and it is where the sanitiser you forgot about turns up.

CodeQL turns a program into a database and asks it questions. That is why it can trace a value from an HTTP parameter into a database query three files away — and why anything the extractor did not see simply does not exist as far as every query is concerned.

  • CodeQL builds a relational database of the program, then runs queries against it
  • Incomplete extraction produces zero findings, which looks exactly like clean code
  • Language identifiers are specific: c-cpp, csharp, go, java-kotlin, javascript-typescript, python, ruby, rust, swift, actions
  • The actions language analyses workflow files, and is under-enabled relative to its value
  • build-mode: none trades accuracy for convenience, and skips Kotlin in a Java repository
  • Query suites run from default to security-extended to security-and-quality
  • Security queries are taint-tracking configurations of sources, sinks and sanitisers
  • A finding is a path; the path is what you triage, not the title
  • paths-ignore reduces noise by removing code from analysis entirely
  • Scheduled runs are what apply new queries to existing code

Use a disposable public repository in a language you know well.

  1. Enable default setup. Note which languages it detected. Predict: did it find everything in the repository?

  2. Switch to advanced setup and read the generated workflow — the matrix, the build mode, the category.

  3. Add code with a clear taint path: a request parameter concatenated into a shell command. Predict: does the analysis flag it, and does the alert show the path?

  4. Add a sanitising function between them that CodeQL is unlikely to model — a custom validator. Predict: does the alert persist? Read the path to see which hop it now takes.

  5. Add paths-ignore covering that file and re-run. Predict: is the alert dismissed, or does it disappear? These are different outcomes with different records.

  6. Switch the query suite to security-extended and compare finding counts.

  7. If you have a compiled language available, set build-mode: none and then autobuild, and compare what each finds.

  8. Delete the repository.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.