Describe SObject: Reading Salesforce Metadata at Runtime
What Describe Is and Why You Need It
Salesforce Apex is a statically typed language, but the org it runs in is dynamic. Fields get added, objects get customised, profiles get tightened — and your code has to survive all of it. The Describe API is how you inspect the schema at runtime instead of hardcoding assumptions that break when an admin changes something.
Three concrete situations where you need it:
- Field-Level Security enforcement — before reading or writing a field, confirm the running user actually has access. Without this check, your code silently exposes data to users who shouldn't see it, or throws cryptic exceptions on DML.
- Dynamic SOQL builders — when you're assembling a query from user input or configuration, you need to know which fields exist on an object before you put them in the SELECT clause.
- Generic utility classes — a class that works on any SObject type (an audit logger, a field comparison utility, a CSV exporter) has to introspect fields it can't know at compile time.
The Describe API lives in the Schema namespace. Everything you need flows from two result types: Schema.DescribeSObjectResult (object-level metadata) and Schema.DescribeFieldResult (field-level metadata).
Getting the DescribeSObjectResult
There are two ways to get a DescribeSObjectResult. The first uses the global Schema.describeSObjects() method, which accepts a list of SObject API names as strings and returns a list of results in the same order.
// Dynamic approach — object name comes from a variable or configuration
Schema.DescribeSObjectResult accountDescribe =
Schema.describeSObjects(new List<String>{'Account'})[0];
System.debug('API Name : ' + accountDescribe.getName());
System.debug('Label : ' + accountDescribe.getLabel());
System.debug('Plural : ' + accountDescribe.getLabelPlural());
System.debug('Createable: ' + accountDescribe.isCreateable());
System.debug('Queryable : ' + accountDescribe.isQueryable());
System.debug('Custom : ' + accountDescribe.isCustom());
The second approach uses the compile-time token syntax. When you know the object type at compile time, this is faster because Salesforce can optimise it — and it avoids the overhead of the string-based lookup.
// Compile-time token approach — preferred when the type is known
Schema.DescribeSObjectResult accountDescribe =
Schema.SObjectType.Account;
// Or equivalently:
Schema.DescribeSObjectResult accountDescribe2 =
Account.sObjectType.getDescribe();
Key methods on DescribeSObjectResult:
getName()— the API name (Account,My_Custom_Object__c)getLabel()— the human-readable singular labelgetLabelPlural()— the plural labelisCreateable()— can the running user create records of this typeisUpdateable()— can the running user update recordsisDeletable()— can the running user delete recordsisQueryable()— can the running user run SOQL against this objectisCustom()— is this a custom object (__csuffix)fields.getMap()— returns the full field map (see next section)getRecordTypeInfos()— returns record type metadatagetChildRelationships()— returns child relationship metadata
DescribeFieldResult — Inspecting Individual Fields
Every object describe result exposes a fields property. Calling .getMap() on it gives you a Map<String, Schema.SObjectField> keyed by the lowercase field API name. From there, calling .getDescribe() on a field token gives you a DescribeFieldResult.
// Get the full field map for Account
Map<String, Schema.SObjectField> fieldMap =
Schema.SObjectType.Account.fields.getMap();
// Get the describe result for a specific field
Schema.DescribeFieldResult nameField =
fieldMap.get('name').getDescribe();
System.debug('Label : ' + nameField.getLabel());
System.debug('Type : ' + nameField.getType());
System.debug('Length : ' + nameField.getLength());
System.debug('Accessible : ' + nameField.isAccessible());
System.debug('Updateable : ' + nameField.isUpdateable());
System.debug('Createable : ' + nameField.isCreateable());
System.debug('Nillable : ' + nameField.isNillable());
System.debug('Unique : ' + nameField.isUnique());
System.debug('External ID: ' + nameField.isExternalId());
The getType() method returns a Schema.DisplayType enum value. The full set of values includes:
Schema.DisplayType.STRINGSchema.DisplayType.BOOLEANSchema.DisplayType.CURRENCYSchema.DisplayType.DATESchema.DisplayType.DATETIMESchema.DisplayType.DOUBLESchema.DisplayType.EMAILSchema.DisplayType.IDSchema.DisplayType.INTEGERSchema.DisplayType.LONGSchema.DisplayType.MULTIPICKLISTSchema.DisplayType.PERCENTSchema.DisplayType.PHONESchema.DisplayType.PICKLISTSchema.DisplayType.REFERENCESchema.DisplayType.TEXTAREASchema.DisplayType.URL
You can use these enum values in conditionals to branch based on field type — for example, to format values differently in an export, or to skip reference fields in a generic copy utility.
Practical Example 1: FLS Check Before DML
Field-Level Security (FLS) restricts which fields a profile or permission set can read or write. Apex runs in system mode by default, meaning it ignores FLS unless you explicitly enforce it. In user-facing code — Visualforce controllers, Aura, LWC server-side actions — you must check FLS before any DML that writes a field value.
public class AccountPhoneUpdater {
public static void updatePhone(Id accountId, String newPhone) {
// Check CRUD: can the user update Account records at all?
if (!Schema.SObjectType.Account.isUpdateable()) {
throw new System.NoAccessException();
}
// Check FLS: can the user write to the Phone field specifically?
if (!Schema.SObjectType.Account.fields.Phone.isUpdateable()) {
throw new System.NoAccessException();
}
// Safe to proceed
Account acc = [SELECT Id FROM Account WHERE Id = :accountId LIMIT 1];
acc.Phone = newPhone;
update acc;
}
}
The compile-time token syntax (Schema.SObjectType.Account.fields.Phone) is preferred here because the field is known at compile time. It's more readable than a map lookup and Salesforce validates it at compile time — a typo in the field name fails the build rather than throwing a NullPointerException at runtime.
If you need to check multiple fields in one pass, use the map approach and loop:
public class FLSChecker {
public static void assertUpdateable(String objectName, List<String> fieldNames) {
Map<String, Schema.SObjectField> fieldMap =
Schema.describeSObjects(new List<String>{objectName})[0]
.fields.getMap();
for (String fieldName : fieldNames) {
Schema.SObjectField field = fieldMap.get(fieldName.toLowerCase());
if (field == null) {
throw new IllegalArgumentException(
'Field ' + fieldName + ' does not exist on ' + objectName
);
}
if (!field.getDescribe().isUpdateable()) {
throw new System.NoAccessException();
}
}
}
}
Practical Example 2: Dynamic Field List Builder
A common pattern in generic utilities is building a SOQL query that includes all accessible fields on an object — useful for audit logs, record clone utilities, or configurable exports. The approach: iterate the field map, filter by isAccessible(), and join the API names into a SELECT clause.
public class DynamicFieldQuery {
public static List<SObject> queryAllAccessibleFields(
String objectApiName, String whereClause) {
// Get the field map for the target object
Schema.DescribeSObjectResult objectDescribe =
Schema.describeSObjects(new List<String>{objectApiName})[0];
Map<String, Schema.SObjectField> fieldMap =
objectDescribe.fields.getMap();
// Build field list: only fields the current user can read
List<String> accessibleFields = new List<String>();
for (String fieldName : fieldMap.keySet()) {
Schema.DescribeFieldResult dfr = fieldMap.get(fieldName).getDescribe();
if (dfr.isAccessible()) {
accessibleFields.add(fieldName);
}
}
if (accessibleFields.isEmpty()) {
return new List<SObject>();
}
// Assemble the SOQL string
String soql = 'SELECT '
+ String.join(accessibleFields, ', ')
+ ' FROM ' + objectApiName;
if (String.isNotBlank(whereClause)) {
soql += ' WHERE ' + whereClause;
}
return Database.query(soql);
}
}
A few important notes about this pattern:
- The
whereClauseparameter must be constructed safely. Never concatenate raw user input — this example assumes the caller passes a trusted string. For user-supplied filters, useDatabase.queryWithBinds()with bind variables. - Long-text area fields (
TEXTAREAwith length > 255) cannot appear in SOQL ORDER BY clauses. If your query adds ordering, filter those types out of the field list. - Some fields are accessible but not useful in bulk queries — compound fields like
BillingAddressreference the same data as their component fields. Consider filtering bySchema.DisplayTypeif you want a clean result set.
To filter by field type, add a type check inside the loop:
// Example: exclude compound and base64 fields from the query
Schema.DisplayType fieldType = dfr.getType();
if (dfr.isAccessible()
&& fieldType != Schema.DisplayType.BASE64
&& fieldType != Schema.DisplayType.ADDRESS) {
accessibleFields.add(fieldName);
}
Practical Example 3: Picklist Values at Runtime
When you need to render a picklist dynamically — in a custom UI component backed by Apex, or to validate that a given value is a valid active picklist entry — you use getPicklistValues() on the DescribeFieldResult. It returns a List<Schema.PicklistEntry>.
public class PicklistHelper {
// Returns a map of value => label for all active picklist entries
public static Map<String, String> getActivePicklistValues(
String objectApiName, String fieldApiName) {
Schema.DescribeSObjectResult objectDescribe =
Schema.describeSObjects(new List<String>{objectApiName})[0];
Map<String, Schema.SObjectField> fieldMap =
objectDescribe.fields.getMap();
Schema.SObjectField field = fieldMap.get(fieldApiName.toLowerCase());
if (field == null) {
throw new IllegalArgumentException(
'Field ' + fieldApiName + ' not found on ' + objectApiName
);
}
Schema.DescribeFieldResult dfr = field.getDescribe();
// Confirm the field is actually a picklist type before calling getPicklistValues
Schema.DisplayType fieldType = dfr.getType();
if (fieldType != Schema.DisplayType.PICKLIST
&& fieldType != Schema.DisplayType.MULTIPICKLIST) {
throw new IllegalArgumentException(
fieldApiName + ' is not a picklist field'
);
}
Map<String, String> result = new Map<String, String>();
for (Schema.PicklistEntry entry : dfr.getPicklistValues()) {
if (entry.isActive()) {
result.put(entry.getValue(), entry.getLabel());
}
}
return result;
}
// Validates that a given value is a valid active picklist entry
public static Boolean isValidPicklistValue(
String objectApiName, String fieldApiName, String value) {
Map<String, String> validValues =
getActivePicklistValues(objectApiName, fieldApiName);
return validValues.containsKey(value);
}
}
The Schema.PicklistEntry interface exposes three methods you'll use regularly:
getValue()— the stored API value (what goes into the database)getLabel()— the display label shown in the UI (may differ from the value)isActive()— whether the entry is currently active; inactive entries may exist on existing records but shouldn't be offered for new selectionsisDefaultValue()— whether this entry is the field's configured default
A practical use: before inserting or updating a record with a picklist field value that came from an external system or user input, call isValidPicklistValue() to reject invalid values early with a meaningful error, rather than letting the database reject it with a generic platform error.
Performance: Cache Describe Results
Describe calls are among the more expensive operations in Apex. Each call to Schema.describeSObjects() or fields.getMap() makes a metadata request that counts against your transaction overhead. Calling them inside a loop is the most common mistake — you pay the cost for every iteration.
The standard pattern is to cache results in static variables on a utility class. Static variables persist for the lifetime of the transaction, so the describe call happens once no matter how many times the method is called.
public class SchemaCache {
// Static cache — populated once per transaction, reused on subsequent calls
private static final Map<String, Schema.DescribeSObjectResult> OBJECT_CACHE =
new Map<String, Schema.DescribeSObjectResult>();
private static final Map<String, Map<String, Schema.SObjectField>> FIELD_MAP_CACHE =
new Map<String, Map<String, Schema.SObjectField>>();
public static Schema.DescribeSObjectResult getObjectDescribe(String objectApiName) {
String key = objectApiName.toLowerCase();
if (!OBJECT_CACHE.containsKey(key)) {
OBJECT_CACHE.put(
key,
Schema.describeSObjects(new List<String>{objectApiName})[0]
);
}
return OBJECT_CACHE.get(key);
}
public static Map<String, Schema.SObjectField> getFieldMap(String objectApiName) {
String key = objectApiName.toLowerCase();
if (!FIELD_MAP_CACHE.containsKey(key)) {
FIELD_MAP_CACHE.put(
key,
getObjectDescribe(objectApiName).fields.getMap()
);
}
return FIELD_MAP_CACHE.get(key);
}
public static Schema.DescribeFieldResult getFieldDescribe(
String objectApiName, String fieldApiName) {
Map<String, Schema.SObjectField> fieldMap = getFieldMap(objectApiName);
Schema.SObjectField field = fieldMap.get(fieldApiName.toLowerCase());
if (field == null) {
throw new IllegalArgumentException(
'Field ' + fieldApiName + ' does not exist on ' + objectApiName
);
}
return field.getDescribe();
}
}
With this cache in place, calling code never calls describe directly:
// Called once — describe happens here
Schema.DescribeFieldResult dfr =
SchemaCache.getFieldDescribe('Account', 'Industry');
// Called 200 times in a loop — cache hit every time, no describe overhead
for (Account acc : accountList) {
if (dfr.isAccessible()) {
// process field
}
}
Two additional rules:
- Never call describe inside a for loop — even if you're not using a cache class, pull the describe call above the loop boundary before iterating.
- Compile-time tokens are faster than string lookups — if you know the object and field at compile time,
Schema.SObjectType.Account.fields.Phone.getDescribe()is faster than going through the string-keyed map. Use the string-based approach only when the object or field name is dynamic.
Putting It Together: A Reusable FLS-Safe DML Utility
Here is a complete, compilable utility class combining describe-based CRUD checks, FLS checks, and caching into a single place that your entire codebase can delegate to:
public class SafeDML {
// Throws NoAccessException if the current user cannot create records
// of the given type, or cannot write to any of the specified fields.
public static void assertInsertable(SObject record, List<String> fieldsToCheck) {
String objectName = record.getSObjectType().getDescribe().getName();
if (!record.getSObjectType().getDescribe().isCreateable()) {
throw new System.NoAccessException();
}
Map<String, Schema.SObjectField> fieldMap =
SchemaCache.getFieldMap(objectName);
for (String fieldName : fieldsToCheck) {
Schema.SObjectField field = fieldMap.get(fieldName.toLowerCase());
if (field != null && !field.getDescribe().isCreateable()) {
throw new System.NoAccessException();
}
}
}
// Throws NoAccessException if the current user cannot update records
// of the given type, or cannot write to any of the specified fields.
public static void assertUpdateable(SObject record, List<String> fieldsToCheck) {
String objectName = record.getSObjectType().getDescribe().getName();
if (!record.getSObjectType().getDescribe().isUpdateable()) {
throw new System.NoAccessException();
}
Map<String, Schema.SObjectField> fieldMap =
SchemaCache.getFieldMap(objectName);
for (String fieldName : fieldsToCheck) {
Schema.SObjectField field = fieldMap.get(fieldName.toLowerCase());
if (field != null && !field.getDescribe().isUpdateable()) {
throw new System.NoAccessException();
}
}
}
// Safe insert: checks CRUD + FLS before executing
public static void safeInsert(SObject record, List<String> fieldsToCheck) {
assertInsertable(record, fieldsToCheck);
insert record;
}
// Safe update: checks CRUD + FLS before executing
public static void safeUpdate(SObject record, List<String> fieldsToCheck) {
assertUpdateable(record, fieldsToCheck);
update record;
}
}
Usage in a controller or service class:
Account acc = new Account(
Name = 'Acme Corp',
Phone = '415-555-0100',
Industry = 'Technology'
);
SafeDML.safeInsert(acc, new List<String>{'Name', 'Phone', 'Industry'});
If the running user's profile restricts the Industry field from being created, SafeDML.safeInsert() throws NoAccessException before any DML is attempted — no silent data exposure, no confusing platform errors at the database layer.
Summary
Use the Describe API whenever your Apex needs to be aware of the schema it's operating on rather than assuming it. The two entry points — Schema.DescribeSObjectResult for object-level metadata and Schema.DescribeFieldResult for field-level metadata — give you everything: labels, types, FLS flags, picklist entries, and child relationships. The cardinal performance rule is to cache describe results in static variables and never call describe inside a loop. For known object and field names, the compile-time token syntax (Schema.SObjectType.Account.fields.Phone) is both faster and compile-time validated. For dynamic scenarios where names come from configuration or user input, use the string-based map approach through a caching utility. Combining describe with explicit FLS checks before every DML operation in user-facing code is not optional — it is the difference between code that is secure and code that silently violates field-level permissions.
Frequently Asked Questions
What is the difference between Schema.describeSObjects() and the compile-time token approach?
Schema.describeSObjects() accepts object API names as strings, making it useful when the object type is determined at runtime from configuration or user input. The compile-time token syntax (Schema.SObjectType.Account) is faster because Salesforce can optimize it at compile time and avoids the overhead of a string-based lookup — use it whenever the object type is known in advance.
Why can't you just read a field directly without checking Field-Level Security first?
Salesforce does not automatically throw a clear exception when code reads a field a user lacks access to — it may silently return null or fail during DML in unpredictable ways. Explicitly checking isAccessible() or isUpdateable() on the DescribeFieldResult before touching the field enforces FLS in code, which is required for AppExchange security reviews and prevents accidental data exposure.
How do you retrieve metadata for a specific field once you have a DescribeSObjectResult?
Call getFields() on the DescribeSObjectResult to get a Map<String, Schema.SObjectField> keyed by field API name, then call getDescribe() on the SObjectField token to get a Schema.DescribeFieldResult. From there, methods like getType(), getLabel(), isAccessible(), and getPicklistValues() give you full field-level metadata.
Does calling Schema.describeSObjects() count against Apex governor limits?
Describe calls are governed by a per-transaction limit of 100 calls to Schema.describeSObjects() and 100 calls to DescribeSObjectResult.getFields(). For generic utilities that process many objects or fields, cache the DescribeSObjectResult or DescribeFieldResult in a static map rather than calling describe repeatedly inside a loop to stay within those limits.