Salesforce

Dynamic SOQL in Apex: Runtime Queries Done Right

What Dynamic SOQL Is and When You Need It

Static SOQL is compiled and validated at deploy time. Every field name, object name, and filter condition is fixed. That works for the majority of Apex code — but it breaks down the moment any part of the query is unknown until runtime.

Dynamic SOQL lets you build a query string in Apex and execute it at runtime using Database.query(). The query is a plain String. You assemble it, pass it to the runtime, and get back a list of records.

Common scenarios where you need it:

  • User-configurable reports — the user selects which fields to display and which filters to apply. You can't know these at compile time.
  • Generic utility classes — a service that accepts an object API name and operates on arbitrary sObjects. Static SOQL can't reference dynamic object names.
  • Dynamic search — search across multiple fields with optional filters that may or may not be present depending on user input.
  • Metadata-driven automation — configurations stored in Custom Metadata that specify which fields to read or which criteria to apply.

The trade-off is real: dynamic SOQL gives you flexibility, but it sacrifices the compile-time safety net and introduces the risk of SOQL injection. Every technique in this tutorial addresses those risks directly.

Database.query() — The Basics

Database.query() accepts a SOQL string and returns a list of sObjects. You can type the return as either List<SObject> or a concrete type like List<Account> — as long as the query targets that object.

public class DynamicQueryBasic {
    public static List<Account> getAccountsByIndustry(String industry) {
        String soql = 'SELECT Id, Name, Phone, AnnualRevenue '
                    + 'FROM Account '
                    + 'WHERE Industry = \'' + industry + '\' '
                    + 'ORDER BY Name ASC '
                    + 'LIMIT 200';

        return Database.query(soql);
    }
}

This works. It also has a critical vulnerability — covered in the next section. For now, understand the mechanics:

  • The return type can be List<SObject> or the specific concrete type (e.g., List<Account>) — Apex coerces it at runtime.
  • If the query returns zero records, you get an empty list — not null, not an exception.
  • If the query is malformed, Apex throws a System.QueryException at runtime, not at compile time.
  • All normal governor limits apply: the 100-SOQL-queries-per-transaction limit counts dynamic queries the same as static ones.

Dynamic SOQL also supports aggregate queries. In that case, use List<AggregateResult>:

public class DynamicAggregateExample {
    public static List<AggregateResult> countByIndustry() {
        String soql = 'SELECT Industry, COUNT(Id) total '
                    + 'FROM Account '
                    + 'GROUP BY Industry';

        return Database.query(soql);
    }
}

SOQL Injection — The Real Threat

SOQL injection is the Salesforce equivalent of SQL injection. When you concatenate user-supplied input directly into a SOQL string, an attacker can manipulate the query to return records they're not supposed to see — or bypass your WHERE clause entirely.

The Attack

Suppose a user passes Technology' OR Name LIKE '% as the industry filter. Your code concatenates it directly:

// VULNERABLE — do not use this pattern
public class VulnerableQuery {
    public static List<Account> search(String userInput) {
        // If userInput = "Technology' OR Name LIKE '%"
        // The assembled string becomes:
        // SELECT Id, Name FROM Account WHERE Industry = 'Technology' OR Name LIKE '%'
        // This returns ALL accounts in the org — not just Technology ones
        String soql = 'SELECT Id, Name FROM Account WHERE Industry = \'' + userInput + '\'';
        return Database.query(soql);
    }
}

The injected input closes the string literal early with ', appends OR Name LIKE '%' — which matches every record — and the query returns the entire Account table regardless of sharing rules applied at the query level.

Fix 1: String.escapeSingleQuotes()

String.escapeSingleQuotes() prepends a backslash before every single quote in the input. This neutralises the injection by preventing the attacker from closing the string literal early.

public class EscapedQuery {
    public static List<Account> search(String userInput) {
        String safeInput = String.escapeSingleQuotes(userInput);
        String soql = 'SELECT Id, Name FROM Account WHERE Industry = \'' + safeInput + '\'';
        return Database.query(soql);
    }
}

When it's not enough: escapeSingleQuotes() only escapes single quotes. It does not protect numeric fields, integer parameters, or any context where the injected value isn't inside a string literal. If you're dynamically building a LIMIT value, an OFFSET, or a numeric comparison, this method does nothing to protect you. For numeric values, always parse explicitly and validate the range. For string fields, prefer bind variables (below) over escaping.

Fix 2: Bind Variables with Database.queryWithBinds()

Bind variables treat the value as data, not as part of the query syntax. The query parser never sees the user's input as part of the SOQL string — injection is structurally impossible. This is the preferred approach.

public class SafeBindQuery {
    public static List<Account> search(String userInput) {
        Map<String, Object> binds = new Map<String, Object>{
            'industry' => userInput
        };

        // :industry in the string is a placeholder that gets replaced with
        // the bound value — the value is never parsed as SOQL syntax
        String soql = 'SELECT Id, Name FROM Account WHERE Industry = :industry';

        return Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
    }
}

The colon-prefixed name in the SOQL string (:industry) must match a key in the binds map exactly. The runtime substitutes the value as a typed parameter — not string concatenation.

Database.queryWithBinds() — The Safe Way

Database.queryWithBinds() was introduced in API version 57.0. It takes three arguments:

  1. The SOQL string with :paramName placeholders.
  2. A Map<String, Object> where each key matches a placeholder in the query.
  3. An AccessLevel enum value: AccessLevel.USER_MODE or AccessLevel.SYSTEM_MODE.
public class QueryWithBindsExample {

    // Multi-filter dynamic query using bind variables
    public static List<Account> findAccounts(String industry, Decimal minRevenue) {
        Map<String, Object> binds = new Map<String, Object>{
            'industry'   => industry,
            'minRevenue' => minRevenue
        };

        String soql = 'SELECT Id, Name, AnnualRevenue, Phone '
                    + 'FROM Account '
                    + 'WHERE Industry = :industry '
                    + 'AND AnnualRevenue >= :minRevenue '
                    + 'ORDER BY AnnualRevenue DESC '
                    + 'LIMIT 50';

        return Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
    }

    // Bind variable with IN clause — pass a List or Set directly
    public static List<Contact> findContactsByAccountIds(Set<Id> accountIds) {
        Map<String, Object> binds = new Map<String, Object>{
            'accountIds' => accountIds
        };

        String soql = 'SELECT Id, FirstName, LastName, Email '
                    + 'FROM Contact '
                    + 'WHERE AccountId IN :accountIds';

        return Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
    }
}

Why bind variables are safer than escapeSingleQuotes():

  • The value is typed — a Decimal bind can never contain a SOQL keyword; a Set<Id> bind can never break an IN clause.
  • There is no string parsing of the user value at the SOQL level. Injection is not a parsing problem that you can forget to escape — it simply doesn't apply.
  • The AccessLevel parameter also enforces field-level security at the query level when you use USER_MODE, giving you an extra layer of defence.

Use AccessLevel.USER_MODE for any query that runs in a user-facing context. Use AccessLevel.SYSTEM_MODE only for system or admin operations where bypassing FLS is intentional and documented.

Building Dynamic Field Lists Safely

Sometimes the set of fields to query is itself dynamic — for example, when building a generic data loader or a configurable report. The correct approach is to build the field list from the object's describe metadata and filter to only those fields the current user can access.

public class DynamicFieldListBuilder {

    // Returns a SOQL query string for the given SObject type,
    // including only fields accessible to the current user.
    public static List<SObject> queryAllAccessibleFields(String objectApiName) {
        // Get the field map for the object
        Schema.SObjectType sObjectType = Schema.getGlobalDescribe().get(objectApiName);

        if (sObjectType == null) {
            throw new IllegalArgumentException('Unknown sObject type: ' + objectApiName);
        }

        Schema.DescribeSObjectResult describeResult = sObjectType.getDescribe();
        Map<String, Schema.SObjectField> fieldMap = describeResult.fields.getMap();

        // Build the field list — only include fields the current user can read
        List<String> accessibleFields = new List<String>();
        for (String fieldName : fieldMap.keySet()) {
            Schema.DescribeFieldResult fieldDescribe = fieldMap.get(fieldName).getDescribe();
            if (fieldDescribe.isAccessible()) {
                accessibleFields.add(fieldName);
            }
        }

        if (accessibleFields.isEmpty()) {
            throw new System.NoAccessException();
        }

        // Join the field list into a comma-separated string
        String fieldList = String.join(accessibleFields, ', ');
        String soql = 'SELECT ' + fieldList + ' FROM ' + String.escapeSingleQuotes(objectApiName);

        return Database.queryWithBinds(soql, new Map<String, Object>(), AccessLevel.USER_MODE);
    }

    // More targeted version: query specific fields but skip any that aren't accessible
    public static List<SObject> querySpecificFields(
        String objectApiName,
        List<String> requestedFields
    ) {
        Schema.SObjectType sObjectType = Schema.getGlobalDescribe().get(objectApiName);

        if (sObjectType == null) {
            throw new IllegalArgumentException('Unknown sObject type: ' + objectApiName);
        }

        Map<String, Schema.SObjectField> fieldMap =
            sObjectType.getDescribe().fields.getMap();

        List<String> safeFields = new List<String>();
        for (String requestedField : requestedFields) {
            String lowerField = requestedField.toLowerCase();
            if (fieldMap.containsKey(lowerField)) {
                Schema.DescribeFieldResult fd = fieldMap.get(lowerField).getDescribe();
                if (fd.isAccessible()) {
                    // Use the canonical API name from the describe result, not user input
                    safeFields.add(fd.getName());
                }
            }
        }

        if (safeFields.isEmpty()) {
            return new List<SObject>();
        }

        String fieldList  = String.join(safeFields, ', ');
        String safeObject = String.escapeSingleQuotes(objectApiName);
        String soql       = 'SELECT ' + fieldList + ' FROM ' + safeObject;

        return Database.queryWithBinds(soql, new Map<String, Object>(), AccessLevel.USER_MODE);
    }
}

Two key points in this pattern:

  • Use fd.getName() for field names — take the canonical name from the describe result, never the raw user-supplied string. This prevents any attempt to inject field-like strings that look valid but aren't.
  • Describe calls are cached by the platform — calling getGlobalDescribe() and fields.getMap() in a loop is expensive the first time but cached within the transaction. For high-volume code, cache the describe results in a static variable.

Dynamic ORDER BY, LIMIT, and OFFSET

Dynamic sort columns are a common requirement in data tables. The danger: if you concatenate a user-supplied sort column directly into the query, an attacker can inject arbitrary SOQL. The fix is an allowlist — a hardcoded Set of field names you permit as sort columns.

public class DynamicSortQuery {

    private static final Set<String> ALLOWED_SORT_FIELDS = new Set<String>{
        'name', 'annualrevenue', 'createddate', 'industry', 'phone'
    };

    private static final Set<String> ALLOWED_SORT_DIRECTIONS = new Set<String>{
        'ASC', 'DESC'
    };

    public static List<Account> getAccountsSorted(
        String sortField,
        String sortDirection,
        Integer pageSize,
        Integer pageOffset
    ) {
        // Validate sort field against the allowlist
        if (!ALLOWED_SORT_FIELDS.contains(sortField.toLowerCase())) {
            throw new IllegalArgumentException('Invalid sort field: ' + sortField);
        }

        // Validate direction against the allowlist
        if (!ALLOWED_SORT_DIRECTIONS.contains(sortDirection.toUpperCase())) {
            throw new IllegalArgumentException('Invalid sort direction: ' + sortDirection);
        }

        // Validate numeric parameters — never concatenate unvalidated integers
        if (pageSize == null || pageSize < 1 || pageSize > 2000) {
            pageSize = 50;
        }
        if (pageOffset == null || pageOffset < 0) {
            pageOffset = 0;
        }

        // sortField and sortDirection come from the allowlist — safe to concatenate
        // pageSize and pageOffset are integers — no injection possible after Integer cast
        String soql = 'SELECT Id, Name, Industry, AnnualRevenue, Phone '
                    + 'FROM Account '
                    + 'ORDER BY ' + sortField.toLowerCase() + ' ' + sortDirection.toUpperCase()
                    + ' LIMIT '  + pageSize
                    + ' OFFSET ' + pageOffset;

        return Database.query(soql);
    }
}

LIMIT and OFFSET cannot use bind variables in SOQL — they must be literals in the query string. Because they're integers (not strings), injection is not possible as long as they've been cast or validated as integers first. The allowlist pattern is mandatory for string-type dynamic components like field names and sort directions.

Testing Dynamic SOQL

Test classes work with dynamic SOQL exactly as they do with static SOQL. There is no special setup required. Create test records with @TestSetup or in the test method body, run the query, and assert on the results.

@IsTest
private class DynamicQueryTest {

    @TestSetup
    static void makeData() {
        List<Account> accounts = new List<Account>{
            new Account(Name = 'Acme Corp',    Industry = 'Technology',  AnnualRevenue = 5000000),
            new Account(Name = 'Globex Inc',   Industry = 'Technology',  AnnualRevenue = 2000000),
            new Account(Name = 'Initech LLC',  Industry = 'Finance',     AnnualRevenue = 1000000)
        };
        insert accounts;
    }

    @IsTest
    static void testGetAccountsByIndustry() {
        Test.startTest();
        List<Account> results = QueryWithBindsExample.findAccounts('Technology', 0);
        Test.stopTest();

        System.assertEquals(2, results.size(), 'Expected two Technology accounts');
        for (Account acc : results) {
            System.assertEquals('Technology', acc.Industry);
        }
    }

    @IsTest
    static void testSortedQuery() {
        Test.startTest();
        List<Account> results = DynamicSortQuery.getAccountsSorted('annualrevenue', 'DESC', 10, 0);
        Test.stopTest();

        System.assertEquals(3, results.size());
        System.assert(
            results[0].AnnualRevenue >= results[1].AnnualRevenue,
            'Results should be sorted descending by AnnualRevenue'
        );
    }

    @IsTest
    static void testInvalidSortFieldThrows() {
        Boolean exceptionThrown = false;
        try {
            DynamicSortQuery.getAccountsSorted('injected__c; DROP TABLE--', 'ASC', 10, 0);
        } catch (IllegalArgumentException e) {
            exceptionThrown = true;
        }
        System.assert(exceptionThrown, 'Expected exception for invalid sort field');
    }

    @IsTest
    static void testDynamicFieldList() {
        Test.startTest();
        List<SObject> results = DynamicFieldListBuilder.querySpecificFields(
            'Account',
            new List<String>{'Name', 'Industry', 'AnnualRevenue'}
        );
        Test.stopTest();

        System.assertEquals(3, results.size());
        // Verify fields are present on the returned records
        Account first = (Account) results[0];
        System.assertNotEquals(null, first.Name);
    }
}

Points to note:

  • Database.query() in tests respects @TestSetup data the same as any SOQL query — no additional configuration needed.
  • Test governor limits are the same as production. If your dynamic query is inside a loop in production, it will hit the 101-limit in tests too, which is the correct behaviour.
  • To test injection protection, write a test that passes malicious input and asserts that an exception is thrown — or that the results are correctly restricted to the expected set. Don't rely on manual verification.
  • When testing Database.queryWithBinds() with AccessLevel.USER_MODE, the running user in tests is the test user. If you need to test FLS-restricted behaviour, use System.runAs() with a user who has restricted permissions.

Complete Reference: All Patterns in One Class

public with sharing class DynamicSOQLService {

    private static final Set<String> ALLOWED_ACCOUNT_SORT_FIELDS = new Set<String>{
        'name', 'annualrevenue', 'createddate', 'industry'
    };

    // -------------------------------------------------------------------------
    // VULNERABLE PATTERN — shown for educational purposes only
    // -------------------------------------------------------------------------
    public static List<Account> vulnerableSearch(String userInput) {
        // DO NOT USE: userInput is concatenated directly — susceptible to injection
        String soql = 'SELECT Id, Name FROM Account WHERE Industry = \'' + userInput + '\'';
        return Database.query(soql);
    }

    // -------------------------------------------------------------------------
    // SAFE PATTERN 1: escapeSingleQuotes (acceptable, not preferred)
    // -------------------------------------------------------------------------
    public static List<Account> safeSearchEscaped(String userInput) {
        String safe = String.escapeSingleQuotes(userInput);
        String soql = 'SELECT Id, Name FROM Account WHERE Industry = \'' + safe + '\'';
        return Database.query(soql);
    }

    // -------------------------------------------------------------------------
    // SAFE PATTERN 2: bind variables (preferred)
    // -------------------------------------------------------------------------
    public static List<Account> safeSearchBinds(String userInput) {
        Map<String, Object> binds = new Map<String, Object>{'industry' => userInput};
        String soql = 'SELECT Id, Name, Phone FROM Account WHERE Industry = :industry';
        return Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
    }

    // -------------------------------------------------------------------------
    // SAFE PATTERN 3: dynamic field list via describe
    // -------------------------------------------------------------------------
    public static List<Account> safeQueryWithFieldFilter(List<String> requestedFields) {
        Map<String, Schema.SObjectField> fieldMap =
            Schema.SObjectType.Account.fields.getMap();

        List<String> safeFields = new List<String>();
        for (String f : requestedFields) {
            if (fieldMap.containsKey(f.toLowerCase())) {
                Schema.DescribeFieldResult fd = fieldMap.get(f.toLowerCase()).getDescribe();
                if (fd.isAccessible()) {
                    safeFields.add(fd.getName());
                }
            }
        }

        if (safeFields.isEmpty()) {
            return new List<Account>();
        }

        String soql = 'SELECT ' + String.join(safeFields, ', ') + ' FROM Account LIMIT 200';
        return Database.queryWithBinds(soql, new Map<String, Object>(), AccessLevel.USER_MODE);
    }

    // -------------------------------------------------------------------------
    // SAFE PATTERN 4: dynamic ORDER BY / LIMIT / OFFSET with allowlist
    // -------------------------------------------------------------------------
    public static List<Account> pagedSortedQuery(
        String sortField,
        String sortDir,
        Integer pageSize,
        Integer offset
    ) {
        if (!ALLOWED_ACCOUNT_SORT_FIELDS.contains(sortField.toLowerCase())) {
            sortField = 'name';
        }
        if (sortDir == null || !new Set<String>{'ASC','DESC'}.contains(sortDir.toUpperCase())) {
            sortDir = 'ASC';
        }
        pageSize = (pageSize == null || pageSize < 1 || pageSize > 200) ? 50 : pageSize;
        offset   = (offset == null || offset < 0) ? 0 : offset;

        String soql = 'SELECT Id, Name, Industry, AnnualRevenue '
                    + 'FROM Account '
                    + 'ORDER BY ' + sortField.toLowerCase()
                    + ' '        + sortDir.toUpperCase()
                    + ' LIMIT '  + pageSize
                    + ' OFFSET ' + offset;

        return Database.query(soql);
    }
}

Summary

Use dynamic SOQL when the object name, field list, or filter conditions can't be known at compile time — generic utilities, configurable reports, and metadata-driven automation are the primary drivers. The moment user input touches your SOQL string, you have an injection risk: prefer Database.queryWithBinds() with a bind map over string concatenation, since bind variables make injection structurally impossible rather than just escaped. For any string component that isn't a bind variable — object names, field names in a SELECT list, sort columns — validate against an allowlist or pull the canonical name from the describe API. Pass AccessLevel.USER_MODE as the third argument to queryWithBinds() to enforce FLS at the query level without manual field checks. Test dynamic queries exactly like static ones: insert data, run the method, assert on results — no special configuration needed.

← All articles

Frequently Asked Questions

What is the difference between Database.query() and static SOQL in terms of governor limits?

Dynamic SOQL queries executed via Database.query() count against the same 100-SOQL-queries-per-transaction limit as static SOQL — there is no separate or higher limit. The runtime treats them identically for governor limit tracking, so the flexibility of dynamic queries comes at the same cost as their static counterparts.

What return type should be used when Database.query() targets an aggregate query with GROUP BY?

Aggregate queries that use functions like COUNT(), SUM(), or GROUP BY must be typed as List<AggregateResult> rather than a concrete sObject type like List<Account>. Attempting to cast aggregate results to a concrete object type will throw a runtime exception because the result rows do not correspond to persisted sObject records.

Why does concatenating user input directly into a SOQL string create a security vulnerability?

When unsanitized user input is embedded directly into a query string, an attacker can inject additional SOQL clauses — for example, appending OR Industry != null to bypass filters and return all records. This is SOQL injection, the Salesforce equivalent of SQL injection, and it can expose data the running user was never intended to access. The standard defense is String.escapeSingleQuotes() for string inputs or, preferably, bind variables which prevent injection by design.

When does Apex throw a QueryException for a dynamic SOQL query, and how does it differ from a query that returns no records?

A System.QueryException is thrown at runtime when the SOQL string itself is malformed — for example, referencing a nonexistent field or invalid syntax — because dynamic queries are not validated at compile time. A query that is syntactically valid but matches no records returns an empty List, not null and not an exception, so null checks on the result are unnecessary but empty-list checks may still be required depending on the logic that follows.