← Back to Blogs Developer
Salesforce · Apex · SOQL

Dynamic SOQL,
finally demystified.

Build queries at runtime, bind values safely, and finally understand the difference between Database.query and Database.queryWithBinds — with hands-on demos you can poke at.

An interactive field guide ~12 min read For Apex developers
Start here

What "dynamic" even means

SOQL — Salesforce Object Query Language — is how Apex asks the database for records. Most of the time you write it statically: the query is fixed in your code, baked in at compile time, and it never changes shape.

Dynamic SOQL is different. Instead of hard-coding the query, you build it as a String while your code is running, then hand that string to a method that executes it. The shape of the query — which fields, which filters, which object — can be decided on the fly.

Static SOQL written once · fixed shape List<Account> a = [SELECT Id, Name FROM Account]; locked at compile time Dynamic SOQL assembled while running String q = 'SELECT ' + fields + ' FROM ' + objectName; decided at run time
Fig 1 — Static is carved in stone. Dynamic is built on the fly.

So when do you actually need it?

Static SOQL covers the vast majority of cases, and you should prefer it when you can. Reach for dynamic SOQL only when the query genuinely has to change at runtime:

  • A search screen where users pick which fields and filters to apply.
  • A reusable utility that queries whatever object name it's handed.
  • Building a WHERE clause from a variable number of conditions.
  • Selecting a dynamic set of fields (for example, all fields on an object).
Method 1

Database.query( )

This is the classic workhorse. You pass it a query string, it runs the query, and it returns the records. The clever part is how it handles values: it reads bind variables — anything written with a colon, like :searchName — by looking for an Apex variable of that exact name in the current scope.

QueryExample.apex
// The Apex variable 'searchName' must exist right here, in scope
String searchName = 'Acme%';

String soql = 'SELECT Id, Name FROM Account ' +
             'WHERE Name LIKE :searchName';

List<Account> accts = Database.query(soql);
// Apex finds the variable named "searchName" and binds its value in

Notice the link: the query says :searchName, and Apex hunts for a variable literally named searchName nearby. The name in the string and the name in the code must match, and the variable has to be reachable from where Database.query runs.

Apex variable (in scope) String searchName query string '…WHERE Name LIKE :searchName' names must match Database.query(soql) → List<Account> of matching records
Fig 2 — Database.query binds by matching :name to an in-scope variable of the same name.
Good to know

You can also pass an access level: Database.query(soql, AccessLevel.USER_MODE). More on that further down.

This works beautifully — until you stop using bind variables and start gluing user input directly into the string. That's where things get dangerous.

The trap

SOQL injection, hands-on

Imagine a search box. A user types a name, and you build the query by concatenating their text straight into the string. Now imagine the user doesn't type a name — they type a fragment of SOQL designed to break out of your filter.

Try it yourself. Type into the box below and flip between the two approaches. Watch what the same input does to the query.

User types into the search box
Resulting query
Try it — concatenation lets input become code; binding keeps it as data.
NeverDoThis.apex
// ❌ DANGEROUS — user input glued straight into the string
String soql = 'SELECT Id FROM Account WHERE Name = \''
             + userInput + '\'';
Database.query(soql);
DoThisInstead.apex
// ✓ SAFE — input arrives as a bound value, never as code
String soql = 'SELECT Id FROM Account WHERE Name = :userInput';
Database.query(soql);
The one rule that prevents most injection

Never concatenate untrusted input into a SOQL string. Bind it instead. If you truly must concatenate (for example, a field or object name, which can't be bound), pass it through String.escapeSingleQuotes() and validate it against an allow-list.

Method 2 · Spring '23 and later

Database.queryWithBinds( )

Here's the catch with the classic method: it resolves bind variables from whatever is in scope. That's fragile. If you build your query string in one class and run it in another, the variable might not exist where the query executes — and you get a confusing error.

Database.queryWithBinds fixes this. Instead of scanning your code for matching variable names, it resolves every :key from a Map you hand it directly. The values no longer need to be in scope — they just need a matching key in the map.

QueryWithBinds.apex
// 1 · Put every value in a Map, keyed by the bind name
Map<String, Object> binds = new Map<String, Object>{
    'industry'   => 'Banking',
    'minRevenue' => 1000000
};

// 2 · The query refers to those keys with a colon
String soql =
    'SELECT Id, Name FROM Account ' +
    'WHERE Industry = :industry' +
    ' AND AnnualRevenue >= :minRevenue';

// 3 · Pass query + map + access level
List<Account> accts = Database.queryWithBinds(
    soql, binds, AccessLevel.USER_MODE
);

This is the heart of the method. Edit the map values below and watch each :key resolve into the query:

Edit the bind map
'industry'
'minRevenue'
Resolved query
Edit the values — each :key resolves from the map into the query.
Why this is the better default

The bind values travel with the query as data. They don't depend on local variable scope, they're safe from injection, and you can build the query in one place and run it in another without anything falling out of scope.

The fine print

Three rules for the bind map

The map is strict on purpose. Break one of these and you'll hit a QueryException at runtime:

MapRules.apex
// ① Every :bind in the query must have a matching key
//    in the map — and every map key should be used.

// ② Keys are case-INSENSITIVE for duplicates.
//    'name' and 'Name' together → QueryException.

// ③ Values can't be null or an empty string.
//    Keys can't be null either.
One handy detail

Bind values can be collections too. A Set<Id> or List<String> in the map works perfectly with an IN :key clause — great for bulk-safe filtering.

The third argument

USER_MODE vs SYSTEM_MODE

That last parameter — AccessLevel — decides whether the query respects the running user's permissions. It's small to type and big in consequence. Flip the switch and see which records come back.

Records the query returns
    Flip the switch — USER_MODE filters by the user's permissions; SYSTEM_MODE doesn't.

    USER_MODE enforces the current user's object permissions, field-level security, and sharing rules — exactly what you usually want in a customer-facing context. SYSTEM_MODE ignores object and field permissions; record sharing then follows the class's with sharing / without sharing keyword. With queryWithBinds the access level is required, which nudges you to make the choice consciously.

    Side by side

    query vs queryWithBinds

    Database.queryDatabase.queryWithBinds
    binds resolved fromin-scope Apex variablesa Map you pass in
    variable must be in scope?YesNo — only a map key
    build & run in different classesfragileclean
    access leveloptional 2nd argrequired 3rd arg
    injection-safe (when bound)YesYes
    available sincealwaysSpring '23 (API 56.0)
    best forsimple, local queriesreusable / cross-class query builders

    The whole family

    The ...WithBinds pattern isn't just for fetching records. It comes in three flavours, all taking the same (query, map, accessLevel) shape:

    TheFamily.apex
    Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);           // → List of records
    Database.countQueryWithBinds(soql, binds, AccessLevel.USER_MODE);      // → Integer count
    Database.getQueryLocatorWithBinds(soql, binds, AccessLevel.USER_MODE); // → QueryLocator
    Take it with you

    The pocket cheat sheet

    Six things to remember when the syntax slips your mind.

    Prefer static SOQL

    Use square-bracket queries unless the shape genuinely has to change at runtime.

    Never concatenate input

    Bind it. :value over '…' + value + '…' every single time.

    query = scope

    Database.query reads :name from an in-scope variable of the same name.

    queryWithBinds = map

    Resolves every :key from a Map<String,Object> — no scope needed.

    Mind the map rules

    No null/empty values, no case-insensitive duplicate keys, every bind needs a key.

    Choose your AccessLevel

    USER_MODE respects the user's permissions; SYSTEM_MODE bypasses them.

    An interactive field guide to Dynamic SOQL · Apex · Salesforce

    Welcome Back

    OR
    Don't have an account? Sign up