CodeQL
CodeQL is GitHub's semantic code-analysis engine, and its whole pitch rests on one deceptively simple move: don't scan source code as text, compile it into a database and query it like any other structured data. Every function call, every assignment, every path a value can take from user input to a dangerous sink becomes a row a purpose-built query language — QL — can search directly. "Find every place untrusted input reaches a SQL call without passing through this sanitizer" stops being a description of a bug in a ticket and becomes an actual query: written once, tested, versioned, and re-run against every future commit. That power has a real cost, which is the tradeoff this whole page keeps circling back to: building that database takes real time and, for compiled languages, a real build — exactly why CodeQL earns its keep on a schedule and inside GitHub's own pull-request checks, rather than on literally every keystroke the way Semgrep does.
Imagine two ways to answer a question about a huge library. One way: walk every aisle yourself, reading page after page, hoping to spot what you need. The other way: first build a complete card catalog that already knows which book cites which, which chapter follows which, who borrowed what and when — then you just ask the catalog a real question, like "show me every book written after 1990 that quotes a book that was banned before 1950," and get an exact answer back instantly. Building that catalog takes real work up front. CodeQL builds that catalog for your code once, and then lets you ask genuinely deep questions of it, over and over, as many times as you want.
What CodeQL is, and the problem it solves
☺ Like you're 10: It doesn't read your code once and forget it — it builds a whole searchable model first, so any question about how data moves through your program has a real, exact answer.
CodeQL began as the query language and engine behind Semmle, a company spun out of research on large-scale, queryable program analysis; GitHub acquired Semmle in 2019 and made CodeQL free to run against public repositories, folding it into GitHub Advanced Security and the code scanning feature built into every GitHub repository's Security tab. (The exact founding history is worth checking against GitHub's own writeup if you need it precisely — what matters for this page is the engine it produced, not the corporate history.) The name is a fair description of the product: Code analyzed via QL, a genuine query language, not a configuration format bolted onto a scanner.
The gap CodeQL fills sits one level deeper than the gap Semgrep fills. Semgrep parses one file into an AST and matches a rule's shape against it, independently, file by file — fast, because it never has to know what a file three directories away contains. CodeQL instead performs extraction: it compiles or parses your entire codebase into a relational-style database that encodes the abstract syntax tree, the control-flow graph, the data-flow graph, and — for most supported languages — a call graph, all at once, with every one of those edges already resolved between files, between functions, sometimes across your whole monorepo. A QL query doesn't reconstruct that structure at query time; it just searches it. That's what lets a single query trace a tainted value from an HTTP handler in one file, through two intermediate functions in two other files, into a database call in a fourth — the exact kind of interprocedural chain Semgrep's per-file model was never built to follow.
CodeQL's core trade is the mirror image of Semgrep's. It spends real time, and for compiled languages a real build, constructing a queryable model of the whole program — in exchange for queries that can trace a vulnerability across function and file boundaries, not just within one file. Depth costs time; that's the whole shape of every gotcha and every scheduling decision later on this page.
Architecture: from source to a queryable database
☺ Like you're 10: First it builds the catalog (once, and it costs time), then it answers questions against that catalog (fast, and you can ask as many as you like).
Producing a CodeQL result is always two separate phases, and keeping them separate in your head is most of what makes the tool's behavior — and its failure modes — predictable.
Phase one, extraction, is codeql database create. For interpreted languages (Python, JavaScript/TypeScript, Ruby) this is essentially a parse — no build needed. For compiled languages (Java/Kotlin, C/C++, C#, Go, Swift), CodeQL has to watch your actual build happen: it wraps or intercepts every compiler invocation your build system makes, so the database reflects exactly what was compiled, with exactly which flags and which conditionally-included files — not a best-effort guess. That's why --command or CodeQL's autobuild step matters so much for compiled projects, and it's the source of most of the gotchas later on this page. Phase two, evaluation, is codeql database analyze: internally, QL compiles down toward relational-algebra-style operations (the standard library and query evaluator both lean on ideas from Datalog), which is what makes exhaustive, whole-program queries tractable rather than a brute-force search. The output is SARIF — the same interchange format most modern SAST tools emit — which is what lets a single result flow into GitHub's code scanning UI, a dashboard like DefectDojo, or any other SARIF consumer.
Default query suites vs. a custom QL query for a homegrown pattern
☺ Like you're 10: The built-in suites already know the dangerous calls every public framework ships with; only you know the dangerous call your own team built and gave a friendly name.
Each language's CodeQL query pack ships three built-in suites, layered by how much you're willing to trade noise for coverage:
| Suite | What it includes | Typical use |
|---|---|---|
code-scanning | A curated, high-confidence set tuned for low false-positive rates | The default the Action runs when you don't override queries: — safe to treat as merge-blocking |
security-extended | Adds lower-confidence and lower-severity security queries the default set leaves out | Broader advisory coverage, often on a schedule rather than as a hard PR gate |
security-and-quality | security-extended plus maintainability and code-quality queries | Teams that want CodeQL to double as a quality tool, not just a security one |
(GitHub periodically renames and re-scopes these suites as its query packs evolve — treat the exact names and boundaries above as the shape to expect, and verify the current set against GitHub's own CodeQL documentation before you rely on precise wording in an audit or a report.) You select one in a config file:
# .github/codeql/codeql-config.yml name: "Acme CodeQL config" queries: - uses: security-extended - uses: ./custom-queries/db-run-sql-injection.ql # your own query, alongside the suite paths-ignore: - "**/*_test.py" - "vendor/**" - "generated/**"
The same limitation that pushed Semgrep past its off-the-shelf ruleset shows up here too, for the same underlying reason: a built-in suite is written to be roughly right across every public codebase that might install it, so it knows a raw database driver's own execute() call as a sink — but has no idea your team wraps that call in an internal db.run() helper three layers down. This course has followed that exact gap before: Secure Coding Patterns and the Semgrep page both watch Benny build a query through Acme's own db.run() wrapper, and a generic ruleset never flags it because it was trained on the driver's own call, not a wrapper three layers removed from it. Semgrep's answer was a taint-mode YAML rule scoped to one file at a time. CodeQL's answer is a real QL query that can trace the same tainted value across function and file boundaries, using the modern module-based data-flow API:
/**
* @name Unsanitized input reaches db.run()
* @description Untrusted HTTP input reaching Acme's db.run() wrapper without
* going through its own bind-parameter form is a SQL injection risk.
* @kind path-problem
* @problem.severity error
* @security-severity 8.6
* @precision high
* @id acme/db-run-sql-injection
* @tags security
* external/cwe/cwe-089
*/
import python
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.TaintTracking
import semmle.python.ApiGraphs
import DataFlow::PathGraph
// A module implementing DataFlow::ConfigSig — the current idiomatic shape.
// Older CodeQL queries you'll find in the wild instead extend a
// `TaintTracking::Configuration` class directly; both compile today, but
// the module form is what GitHub's own docs teach now.
module DbRunConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
// request.args.get(...) / request.form.get(...) — untrusted by definition
source = API::moduleImport("flask").getMember("request")
.getMember(["args", "form"]).getMember("get").getACall()
}
predicate isSink(DataFlow::Node sink) {
exists(API::CallNode run |
run = API::moduleImport("acme_db").getMember("db").getMember("run").getACall() and
sink = run.getArg(0)
)
}
predicate isBarrier(DataFlow::Node node) {
// Acme's own bind-parameter form is the sanitizer — same role the
// Semgrep rule's pattern-sanitizers block played for this exact wrapper.
exists(API::CallNode run |
run = API::moduleImport("acme_db").getMember("db").getMember("run").getACall() and
node = run.getArgByName("params")
)
}
}
module DbRunFlow = TaintTracking::Global<DbRunConfig>;
from DbRunFlow::PathNode source, DbRunFlow::PathNode sink
where DbRunFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
"This query depends on a $@ reaching db.run() without a bind parameter.",
source.getNode(), "user-provided value"Read the shape, not just the syntax: isSource names where untrusted data enters, isSink names where it becomes dangerous, isBarrier names what neutralizes it in between — the exact same source/sink/sanitizer vocabulary static analysis & secrets detection covers generally, just expressed against a real whole-program database instead of one file at a time. The @kind path-problem and the trailing PathNode/flowPath machinery are what let GitHub's UI render the entire chain — every intermediate function call the tainted value passed through — not just the two endpoints, which is the single biggest practical difference a reviewer feels when triaging a CodeQL alert versus a single-line Semgrep finding. The metadata block above the query isn't decorative either: @security-severity and @precision drive how GitHub ranks and displays the alert, and @id is the stable identifier a dismissal or a suppression comment refers back to.
Before a custom query goes anywhere near a merge-blocking pipeline, test it the same way you'd test application code: codeql test run against a small qlpack of known-good and known-bad fixtures with an .expected file recording the exact results the query should produce. Skipping this step is how a query with a typo in its sink predicate ships silently matching nothing, and nobody notices until the exact bug it was written for reappears in production.
How it plugs into GitHub code scanning as a native PR check
☺ Like you're 10: Turn it on with one click and GitHub figures out the rest, or write the exact recipe yourself when you need more control than the one-click version gives you.
GitHub offers two ways to wire CodeQL into a repository, and the choice mostly comes down to how much control you need over what gets scanned and when.
Default setup is a one-click flow under a repository's Settings → Code security → Code scanning: GitHub detects which languages are present, picks a build mode automatically, and runs CodeQL on a GitHub-managed schedule and on pushes — with no workflow file committed to the repository at all. It's the fastest path to coverage and the right default for a team that just wants CodeQL running with sane settings.
Advanced setup commits an actual workflow using github/codeql-action, and it's what you need the moment you want a custom query pack, a config file, path filters, or a schedule that's independent of the PR-triggered run:
# .github/workflows/codeql.yml
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '30 1 * * 1' # weekly deep scan — catches new query-pack coverage on unchanged code
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write # required to upload SARIF and create alerts
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [ 'python', 'javascript-typescript', 'java-kotlin' ]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3 # check the Action's current major-version tag before pinning
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- uses: github/codeql-action/autobuild@v3 # skip this step and add manual build commands
# for a monorepo autobuild can't figure out cleanly
- uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"Every compiled language needs a build mode, and picking the right one is most of what makes an advanced-setup workflow reliable:
| Build mode | What happens | Use it when |
|---|---|---|
none | No build — pure extraction/parse | Interpreted languages: Python, JavaScript/TypeScript, Ruby |
autobuild | CodeQL guesses your build system (Maven, Gradle, Make, MSBuild, …) and runs it | A single, conventional build in one language, one build file |
| manual | You supply the exact build commands the Action should run | Monorepos, multi-module builds, or anything autobuild gets wrong or times out on |
What makes this a genuinely native PR check, not just a scan that happens to run in CI, is what happens after the analyze step uploads its SARIF: results become entries in the repository's Security → Code scanning alerts tab, and any new alert a pull request's diff introduces gets annotated inline on the exact changed line in "Files changed" — a reviewer sees the finding without leaving the PR. Configure the "CodeQL" check as a required status check and a PR that introduces a new alert simply can't merge until it's fixed or the alert is explicitly dismissed with a reason. Alerts that already existed on the target branch before the PR don't block it — the same baseline principle Semgrep's --baseline-commit flag gives you, applied automatically by GitHub's own alert tracking instead of a CLI flag you have to remember. On supported plans, GitHub's Copilot Autofix can also propose a code-level fix suggestion directly on a CodeQL alert — worth knowing exists, but verify current availability and plan requirements against GitHub's own pricing page rather than assuming it's on by default.
CodeQL analysis is free for public repositories, full stop. For private repositories, running code scanning through GitHub requires a GitHub Advanced Security (GHAS) license — a paid add-on on GitHub Enterprise. The CodeQL CLI itself can also be run standalone, outside GitHub Actions entirely, but its license terms restrict that kind of use — broadly, to security research and analyzing code you have the rights to — rather than granting unrestricted commercial use. Read the CLI's actual license terms before you build a private, non-GitHub pipeline around it; don't assume "the binary is downloadable" means "unrestricted to use."
Gotchas and failure modes
☺ Like you're 10: Almost every surprise traces back to the same two facts — building the catalog is expensive, and a clean scan only means the catalog was never asked the question that would have found the problem.
- A guessed build is a database with holes in it.
autobuildworks well for a single conventional build, and quietly under-covers a monorepo with multiple build files, conditional compilation, or a build orchestrator it doesn't recognize — the symptom is usually a suspiciously small database and a scan that reports nothing wrong, which reads as "clean" rather than "incomplete." Switch to manual build commands the moment a project is more than a single straightforward build. - Extraction is not incremental across commits. Every run of
codeql database createre-extracts from the current state of the checkout; there's no equivalent of Semgrep's--baseline-commitshrinking what gets analyzed. That's a real reason CodeQL belongs on a schedule and on merges to main rather than on every push to every branch — the cost doesn't shrink just because the diff is small. - "Zero findings" means the queries you ran found nothing, not that nothing is wrong. The exact same framing this course keeps returning to for every SAST tool applies here too: default-suite coverage models public frameworks' known sinks, and a sink hidden behind your own wrapper is invisible until someone writes the query that names it.
- QL has a real learning curve. It's a genuine declarative, Datalog-flavored language with typed classes and predicates — closer to learning a small logic-programming language than to writing a Semgrep pattern that already looks like the target code. Budget real time for a team's first custom query, and lean on
codeql test runrather than trusting a query that "looks right" against production code on the first try. - One database per language. A vulnerability that crosses a language boundary — a Python service calling into a native C extension, say — isn't traced by a single query, because each language gets its own separately extracted database with no shared data-flow graph between them.
- Alert dismissal doesn't generalize. Dismissing a code-scanning alert as a false positive silences that specific alert; it does not teach the query anything, so the identical pattern reappearing in a different file triggers a fresh alert someone has to dismiss again. Compare Semgrep's inline
nosemgrep: rule-idcomment, which is at least visible in the diff right next to the code it's excusing.
Where CodeQL sits against Semgrep, SonarQube, and the enterprise SAST tier
☺ Like you're 10: Different tools spend different amounts of time to see different amounts of depth — the right pipeline uses more than one, at different moments.
| Tool | Analysis basis | Typical speed | Depth | Fits best at |
|---|---|---|---|---|
| CodeQL | Compiles the codebase into a relational database; real interprocedural data flow via QL queries | Minutes to tens of minutes; a full build step for compiled languages | Deep — traces a tainted value across function and file boundaries | Merges to main, scheduled scans; free on public GitHub repos, GHAS-licensed on private ones |
| Semgrep | Per-file AST pattern matching; lightweight taint mode within a file/project | Seconds to low minutes, even on a large repo | Shallow by default, deliberately — no whole-program database | Every commit, every pull request; the fast, first, cheapest gate |
| SonarQube | A bundled multi-language rule engine plus "Security Hotspots" flagged for human review, wrapped in a Quality Gate | Low minutes; scales with a persistent server, not a stateless CLI | Moderate — broad coverage and code-quality metrics, shallower dataflow than CodeQL | A team-facing dashboard combining security and code quality, with a human review step baked in |
| Commercial SAST (Checkmarx, Veracode, Fortify, …) | Vendor-proprietary engines, generally compiled/whole-program like CodeQL | Minutes to tens of minutes; often a hosted service rather than a local CLI | Deep, with vendor-specific compliance mappings (PCI DSS, FedRAMP) as a selling point | Regulated environments where a named vendor's audit trail and support contract matter as much as the engine itself |
The pattern from the Semgrep page holds here too, just restated from CodeQL's side of it: a mature pipeline runs Semgrep on every commit because it's cheap enough not to notice, and lets CodeQL earn its much higher cost on a schedule and on the services where an interprocedural chain is worth the wait to catch — not because one tool is strictly better, but because each is the right answer to a different question about how much time you're willing to spend for how much depth. GitHub's own native PR integration is CodeQL's other genuine differentiator against the commercial tier: no separate vendor dashboard, no extra login, results and inline annotations live exactly where the reviewer already is. See vulnerability management & triage for how findings from more than one of these tools get deduplicated into a single backlog once a pipeline is running more than one — which, per the table above, most mature ones eventually do.
Rocky the Raccoon: Found another one — a second wrapper, same shape as the last db.run() gap. Different file, same mistake.
Timmy the Turtle: Semgrep's rule only ever looked inside one file at a time, so it never had a chance of connecting this one to the last one. I wrote a CodeQL query instead — it traces the value across every function it passes through before it ever reaches db.run().
Foxy: So why not just run that instead of Semgrep, if it sees more?
Timmy the Turtle: Because building the database it needs takes real minutes, not seconds — nobody's waiting on it before every push. It runs on merges to main and once a week on a schedule. Semgrep still catches the obvious version of this in seconds, on every single PR.
Ellie the Elephant: Two scanners, same bug class, different reach. That's not redundant — that's the point.
Benny the Beaver: Fixing the new one now — bound parameter, same as last time. Starting to feel like I should just stop writing raw queries at all.
Timmy the Turtle: That's the actual fix, eventually. Until then, the query stays in the pack, and it'll catch the next one too.
1. What does "database" mean in CodeQL, and what are the two separate phases required to get a result from it? 2. Name the three built-in query suites and roughly how they differ. 3. Why write a custom QL query instead of relying on the default suites — what's the concrete example this page uses? 4. What's the practical difference between GitHub's default setup and advanced setup for code scanning, and what does advanced setup let you control that default doesn't? 5. Give two gotchas that come specifically from extraction requiring a real build for compiled languages.
Check your answers
- A CodeQL database is a relational-style snapshot of a codebase's AST, control-flow graph, data-flow graph, and call graph, produced by extraction. The two phases are
codeql database create(build/extract the database — once) andcodeql database analyze(run one or more QL queries against it — as many times as you like). code-scanning— a curated, high-confidence set safe to treat as merge-blocking.security-extended— adds lower-confidence and lower-severity security queries the default leaves out, better suited to advisory or scheduled scanning.security-and-quality—security-extendedplus maintainability/code-quality queries.- Default suites model the sinks and sources of public frameworks; they have no idea a team's own internal wrapper (this page's example: an Acme
db.run()helper around a database driver) is functionally the same sink. A custom QL query teaches CodeQL's data-flow engine about that wrapper specifically — the same gap Semgrep's custom taint-mode rule closes, but traced across function and file boundaries instead of within one file. - Default setup is a one-click, no-workflow-file path where GitHub auto-detects languages and a build mode and runs on a GitHub-managed schedule. Advanced setup commits an actual
.github/workflows/codeql.ymlusinggithub/codeql-action, which is what's required to use a custom config file, a custom query pack (like the example query on this page), path filters, matrix builds across languages, or a schedule independent of the PR-triggered run. - Any two of:
autobuildcan pick the wrong build target or fail outright on a monorepo with multiple build files, silently producing an incomplete database that then reports as falsely "clean"; extraction is not incremental across commits, so every run re-extracts fully rather than analyzing just a diff, which is part of why CodeQL doesn't belong on every push; and a failed or partial extraction for a compiled language doesn't always surface as an obvious build error — it can just quietly under-cover the codebase.