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.
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 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:
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.
// 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.
Database.query binds by matching :name to an in-scope variable of the same name.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.
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.
// ❌ DANGEROUS — user input glued straight into the string
String soql = 'SELECT Id FROM Account WHERE Name = \''
+ userInput + '\'';
Database.query(soql);
// ✓ SAFE — input arrives as a bound value, never as code
String soql = 'SELECT Id FROM Account WHERE Name = :userInput';
Database.query(soql);
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.
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.
// 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:
:key resolves from the map into the query.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 map is strict on purpose. Break one of these and you'll hit a QueryException at runtime:
// ① 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.
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.
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.
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.
| Database.query | Database.queryWithBinds | |
|---|---|---|
| binds resolved from | in-scope Apex variables | a Map you pass in |
| variable must be in scope? | Yes | No — only a map key |
| build & run in different classes | fragile | clean |
| access level | optional 2nd arg | required 3rd arg |
| injection-safe (when bound) | Yes | Yes |
| available since | always | Spring '23 (API 56.0) |
| best for | simple, local queries | reusable / cross-class query builders |
The ...WithBinds pattern isn't just for fetching records. It comes in three flavours, all taking the same (query, map, accessLevel) shape:
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
Six things to remember when the syntax slips your mind.
Use square-bracket queries unless the shape genuinely has to change at runtime.
Bind it. :value over '…' + value + '…' every single time.
Database.query reads :name from an in-scope variable of the same name.
Resolves every :key from a Map<String,Object> — no scope needed.
No null/empty values, no case-insensitive duplicate keys, every bind needs a key.
USER_MODE respects the user's permissions; SYSTEM_MODE bypasses them.