Custom Settings in Salesforce: Org-Wide Configuration in Apex
What Custom Settings Are
Custom Settings are custom objects whose records are stored in the application cache rather than the standard database. This distinction matters: reading a Custom Setting record does not consume a SOQL query from your governor limit budget. In a transaction that's already approaching the 100-query limit, this can be the difference between a working trigger and a System.LimitException.
Salesforce provides two types:
- Hierarchy — resolves a single value per field across three levels: Org, Profile, and User. The most specific record wins.
- List — a named set of records, each identified by a unique
Name. Behaves like a key-value store.
Both types are accessible in Apex, Formula fields, Validation Rules, and Workflow Rules without touching the SOQL query count. That cache behaviour is the primary reason you reach for Custom Settings instead of a regular custom object.
Hierarchy Custom Settings
A Hierarchy Custom Setting resolves one effective record per field. When you call its Apex accessor methods, Salesforce applies a precedence rule: a User-level record overrides a Profile-level record, which overrides the Org-level default. The most specific level that has data wins.
Setup
- Go to Setup → Custom Settings → New.
- Set Setting Type to Hierarchy.
- Give it a label and API name (e.g.,
AppConfig__c). - Add custom fields — checkboxes, text, numbers — just like on a standard object.
- Navigate to Manage to insert records at the Org, Profile, or User level.
A typical Hierarchy Custom Setting for feature flags might have a EnableBetaFeature__c checkbox. You enable it at the Org level, but disable it for a specific test Profile so QA doesn't see the beta UI.
Apex Accessor Methods
All four methods return an instance of the Custom Setting SObject, or null if no record exists at that level.
AppConfig__c.getOrgDefaults()— returns the record set at the Org level. Always returns the same record regardless of who's running the code.AppConfig__c.getInstance()— returns the most specific record for the current user: checks User first, then Profile, then Org. This is the method you call in almost every real scenario.AppConfig__c.getInstance(userId)— resolves the hierarchy for a specific User Id. Useful in admin utilities that operate on behalf of another user.AppConfig__c.getInstance(profileId)— resolves for a specific Profile Id.
Feature Flag Example
public class FeatureFlagService {
public static Boolean isBetaFeatureEnabled() {
AppConfig__c config = AppConfig__c.getInstance();
if (config == null) {
// No record exists at any level — default to disabled.
return false;
}
return config.EnableBetaFeature__c == true;
}
public static Boolean isBetaFeatureEnabledForUser(Id userId) {
AppConfig__c config = AppConfig__c.getInstance(userId);
if (config == null) {
return false;
}
return config.EnableBetaFeature__c == true;
}
}
This does not count against your SOQL query limit. The cache is populated once per transaction and reused on subsequent calls within the same request.
Writing to a Hierarchy Custom Setting in Apex
You can also insert or update Custom Setting records via DML. This is how admin utilities or setup scripts configure the setting programmatically.
public class AppConfigSetup {
public static void setOrgDefault(Boolean enableBeta) {
// getOrgDefaults() returns the existing org-level record or a blank instance.
AppConfig__c config = AppConfig__c.getOrgDefaults();
config.EnableBetaFeature__c = enableBeta;
// Upsert handles both the "record exists" and "doesn't exist" cases.
upsert config;
}
public static void setForUser(Id userId, Boolean enableBeta) {
AppConfig__c existing = AppConfig__c.getInstance(userId);
AppConfig__c config;
if (existing != null && existing.SetupOwnerId == userId) {
// A user-level record already exists; update it.
config = existing;
} else {
// No user-level record yet; create one.
config = new AppConfig__c(SetupOwnerId = userId);
}
config.EnableBetaFeature__c = enableBeta;
upsert config;
}
}
The SetupOwnerId field determines the level. If it holds an Org Id (the standard org Id), the record is the Org default. If it holds a Profile Id or User Id, it's scoped to that level.
List Custom Settings
A List Custom Setting is a flat key-value store. Every record has a Name (the key) and whatever custom fields you've added (the values). There is no hierarchy resolution — you retrieve records by their Name directly.
Setup
- Go to Setup → Custom Settings → New.
- Set Setting Type to List.
- Add custom fields for the values you want to store (e.g.,
EndpointURL__c,TaxRate__c). - Navigate to Manage and insert named records (e.g.,
Name = 'Production',EndpointURL__c = 'https://api.example.com').
Apex Accessor Methods
ApiEndpoints__c.getAll()— returns aMap<String, ApiEndpoints__c>of all records, keyed byName.ApiEndpoints__c.getValues('Production')— returns the single record with that name, ornullif it doesn't exist.
Environment-Based Endpoint Lookup
public class ApiEndpointResolver {
// Expected List Custom Setting records:
// Name = 'Production', EndpointURL__c = 'https://api.example.com'
// Name = 'Sandbox', EndpointURL__c = 'https://api-sandbox.example.com'
public static String getEndpoint(String environment) {
ApiEndpoints__c setting = ApiEndpoints__c.getValues(environment);
if (setting == null) {
throw new ApiEndpointResolver.MissingConfigException(
'No API endpoint configured for environment: ' + environment
);
}
return setting.EndpointURL__c;
}
public static Map<String, String> getAllEndpoints() {
Map<String, ApiEndpoints__c> allSettings = ApiEndpoints__c.getAll();
Map<String, String> result = new Map<String, String>();
for (String name : allSettings.keySet()) {
result.put(name, allSettings.get(name).EndpointURL__c);
}
return result;
}
public class MissingConfigException extends Exception {}
}
Because getAll() reads from cache, calling it multiple times within the same transaction does not add to your SOQL count. This makes List Custom Settings ideal for lookup tables that get accessed frequently within a single execution context.
Custom Settings vs Custom Metadata Types
Custom Metadata Types (__mdt) solve a similar problem — storing configuration — but with a fundamentally different architecture. Choosing between them is one of the most common judgment calls in Salesforce development.
Key Differences
| Factor | Custom Settings | Custom Metadata Types |
|---|---|---|
| Storage | Application cache; DML to modify | Metadata; deployed via change sets or unlocked packages |
| SOQL queries | Not consumed (cached access) | Consumed (standard SOQL) |
| Runtime editing | Admins can edit in Setup at any time | Requires deployment |
| Sandbox refresh | Data is not copied on sandbox refresh | Records survive sandbox refresh and deployment |
| Package deployment | Data not included in packages | Records included in packages and deployments |
| Queryable in SOQL | No (use accessor methods) | Yes — SELECT ... FROM MyConfig__mdt |
| Relationships | No lookup or master-detail | Supports lookup to other metadata types |
Decision Guide
Use Custom Settings when:
- An admin needs to change the value in production without a deployment (e.g., toggling a feature flag, updating a rate that changes frequently).
- You need per-user or per-profile configuration (Hierarchy type).
- You're accessing the config very frequently within a transaction and can't afford SOQL queries.
Use Custom Metadata Types when:
- The configuration is part of the application logic and should travel with deployments (e.g., field mappings, routing rules, validation thresholds).
- You need the data to survive sandbox refreshes automatically.
- You need to query with SOQL filters, relationships, or
ORDER BY. - You're building a managed package and want config records bundled with it.
The single most important difference in practice: Custom Settings data does not survive sandbox refreshes. If your config records are created only in Setup and never deployed as metadata, every sandbox refresh wipes them. Teams that don't account for this end up with broken sandbox environments and manual re-entry work after every refresh. Custom Metadata Types do not have this problem.
Accessing Custom Metadata in Apex (for comparison)
// Custom Metadata query — this DOES consume a SOQL query.
List<RoutingRule__mdt> rules = [
SELECT DeveloperName, QueueName__c, Priority__c
FROM RoutingRule__mdt
WHERE IsActive__c = TRUE
ORDER BY Priority__c ASC
];
// Custom Setting access — this does NOT consume a SOQL query.
AppConfig__c config = AppConfig__c.getInstance();
Testing Custom Settings
Custom Settings have clean test isolation behaviour. Records created inside a test method or @TestSetup are scoped to the test transaction and do not affect or require production data. You do not need @IsTest(SeeAllData=true).
The correct pattern is to insert the Custom Setting record at the start of each test or in @TestSetup, then call the production code under test.
@IsTest
private class FeatureFlagServiceTest {
@TestSetup
static void setupSettings() {
// Insert an org-level record for all tests in this class.
AppConfig__c config = new AppConfig__c(
SetupOwnerId = UserInfo.getOrganizationId(),
EnableBetaFeature__c = true
);
insert config;
}
@IsTest
static void testBetaFeatureEnabled() {
// The @TestSetup record is already in place.
Boolean enabled = FeatureFlagService.isBetaFeatureEnabled();
System.assertEquals(true, enabled, 'Beta feature should be enabled at org level.');
}
@IsTest
static void testBetaFeatureDisabledWhenNoRecord() {
// Delete the org-level record to simulate an unconfigured org.
delete [SELECT Id FROM AppConfig__c];
Boolean enabled = FeatureFlagService.isBetaFeatureEnabled();
System.assertEquals(false, enabled, 'Should default to false when no record exists.');
}
@IsTest
static void testUserLevelOverridesOrg() {
Id userId = UserInfo.getUserId();
// Insert a user-level record that disables the feature for this user.
AppConfig__c userConfig = new AppConfig__c(
SetupOwnerId = userId,
EnableBetaFeature__c = false
);
insert userConfig;
// getInstance() should return the user-level record (more specific than org).
Boolean enabled = FeatureFlagService.isBetaFeatureEnabledForUser(userId);
System.assertEquals(false, enabled, 'User-level record should override org default.');
}
}
One common test mistake: calling getOrgDefaults() or getInstance() before inserting any test data and not handling the null return. Always write your production code to handle a null result, and always confirm both the "record present" and "record absent" paths in your tests.
Common Mistakes
Not Handling Null from getOrgDefaults()
If no Org-level record has been created in Setup, getOrgDefaults() returns null. Dereferencing fields on a null object throws a NullPointerException at runtime. Always add a null check.
// BROKEN — throws NullPointerException if no org record exists.
Boolean isEnabled = AppConfig__c.getOrgDefaults().EnableBetaFeature__c;
// CORRECT — null-safe pattern.
AppConfig__c config = AppConfig__c.getOrgDefaults();
Boolean isEnabled = (config != null) ? config.EnableBetaFeature__c == true : false;
The same rule applies to getInstance() and getValues(). Never assume the record exists.
Using Custom Settings for Configuration That Belongs in Custom Metadata
If your configuration records need to travel with deployments — field mappings, business rules, routing logic — use Custom Metadata. Putting them in Custom Settings means you have to manually re-enter data after every sandbox refresh and cannot include them in a deployment package.
Calling getAll() When You Only Need One Record
For List Custom Settings, getAll() loads all records into memory. If the setting has thousands of records, this wastes heap space. When you know the exact key, use getValues('KeyName') instead.
// Inefficient when you only need one record.
Map<String, ApiEndpoints__c> all = ApiEndpoints__c.getAll();
ApiEndpoints__c prod = all.get('Production');
// Efficient — fetches only what you need.
ApiEndpoints__c prod = ApiEndpoints__c.getValues('Production');
Modifying Custom Settings in Triggers Without Bulkification Awareness
Reading Custom Settings in a trigger is fine — the cache means no SOQL cost. But writing to Custom Settings via DML inside a trigger counts against your DML governor limits. If your trigger processes 200 records, you cannot call upsert config once per record. Consolidate writes outside the record loop.
Complete Example: Config-Driven Integration Service
The following ties the concepts together. It uses a Hierarchy Custom Setting to check whether an external integration is enabled and a List Custom Setting to resolve the correct endpoint URL for the current environment.
// Hierarchy Custom Setting: IntegrationConfig__c
// Fields: IsEnabled__c (Checkbox), Environment__c (Text)
// List Custom Setting: IntegrationEndpoints__c
// Records: Name = 'production', EndpointURL__c = 'https://api.example.com'
// Name = 'sandbox', EndpointURL__c = 'https://sandbox.api.example.com'
public class IntegrationService {
private static final String DEFAULT_ENV = 'sandbox';
public static String resolveEndpoint() {
IntegrationConfig__c config = IntegrationConfig__c.getInstance();
if (config == null) {
throw new IntegrationService.ConfigurationException(
'IntegrationConfig__c has no record for this user, profile, or org.'
);
}
if (config.IsEnabled__c != true) {
throw new IntegrationService.IntegrationDisabledException(
'Integration is disabled at the current hierarchy level.'
);
}
String env = String.isBlank(config.Environment__c)
? DEFAULT_ENV
: config.Environment__c.toLowerCase();
IntegrationEndpoints__c endpoint = IntegrationEndpoints__c.getValues(env);
if (endpoint == null) {
throw new IntegrationService.ConfigurationException(
'No endpoint configured for environment: ' + env
);
}
return endpoint.EndpointURL__c;
}
public class ConfigurationException extends Exception {}
public class IntegrationDisabledException extends Exception {}
}
@IsTest
private class IntegrationServiceTest {
@TestSetup
static void setup() {
// Hierarchy setting — org level, enabled, pointing to sandbox environment.
insert new IntegrationConfig__c(
SetupOwnerId = UserInfo.getOrganizationId(),
IsEnabled__c = true,
Environment__c = 'sandbox'
);
// List setting — one record per environment.
insert new List<IntegrationEndpoints__c>{
new IntegrationEndpoints__c(
Name = 'production',
EndpointURL__c = 'https://api.example.com'
),
new IntegrationEndpoints__c(
Name = 'sandbox',
EndpointURL__c = 'https://sandbox.api.example.com'
)
};
}
@IsTest
static void testResolvesCorrectEndpoint() {
String url = IntegrationService.resolveEndpoint();
System.assertEquals('https://sandbox.api.example.com', url);
}
@IsTest
static void testThrowsWhenDisabled() {
// Update the org-level record to disabled.
IntegrationConfig__c config = IntegrationConfig__c.getOrgDefaults();
config.IsEnabled__c = false;
update config;
try {
IntegrationService.resolveEndpoint();
System.assert(false, 'Expected IntegrationDisabledException.');
} catch (IntegrationService.IntegrationDisabledException e) {
System.assert(e.getMessage().contains('disabled'));
}
}
@IsTest
static void testThrowsWhenNoConfig() {
delete [SELECT Id FROM IntegrationConfig__c];
try {
IntegrationService.resolveEndpoint();
System.assert(false, 'Expected ConfigurationException.');
} catch (IntegrationService.ConfigurationException e) {
System.assert(e.getMessage().contains('no record'));
}
}
}
Summary
Custom Settings exist to serve one primary purpose: provide configuration data that can be read inside any Apex transaction — including triggers — without consuming SOQL queries. Use Hierarchy Settings when you need per-user or per-profile overrides of a single value, and List Settings when you need a named lookup table. In both cases, always null-check the return value before accessing fields. Choose Custom Metadata Types instead whenever the configuration should travel with deployments, survive sandbox refreshes, or be queried with filters and ordering — the SOQL cost of Custom Metadata is usually acceptable, and the operational benefits of deployment-coupled config almost always outweigh it.
Frequently Asked Questions
What happens if no Custom Setting record exists at any hierarchy level when getInstance() is called?
getInstance() returns null, so Apex code must always perform a null check before accessing any field on the returned object. A safe pattern is to default to a conservative value — such as false for a feature flag — rather than assuming a record exists.
When should a List Custom Setting be used instead of a Hierarchy Custom Setting?
List Custom Settings work best when the data is keyed by an arbitrary string name rather than resolved per user or profile — for example, a set of named configuration profiles, country codes, or integration endpoint mappings. They do not apply the User → Profile → Org precedence logic, so every caller retrieves records by explicit Name rather than inheriting a context-specific value.
Do Custom Settings count against Apex SOQL governor limits?
No — Custom Setting records are stored in the application cache, so reading them via the Apex accessor methods does not consume any of the 100 SOQL queries allowed per transaction. This makes them preferable to regular custom objects for configuration data that is read frequently inside loops or heavily queried transactions.
Can the same Hierarchy Custom Setting field be enabled org-wide but disabled for a specific profile?
Yes — because the hierarchy resolves to the most specific level with data, setting EnableBetaFeature__c to true at the Org level and then inserting a Profile-level record with the field set to false will cause getInstance() to return false for any user under that profile. Users on other profiles fall through to the Org-level default and receive true.