Salesforce

Custom Settings vs Custom Metadata in Salesforce: When to Use Which

Two mechanisms in Salesforce store configuration data outside of hardcoded values: Custom Settings and Custom Metadata Types. They look similar on the surface — both hold named records with custom fields — but they behave fundamentally differently under the hood. Picking the wrong one creates deployment headaches, SOQL limit problems, or data that gets wiped between sandboxes. This session explains both in depth, shows exactly how to use them from Apex, and gives you a clear decision rule.

Custom Settings

Custom Settings are custom objects whose records are cached in the application server's memory. A SOQL query against a Custom Setting does not count toward your 100-query-per-transaction limit because the platform serves the data from cache. There are two types.

List Custom Settings

A List Custom Setting stores multiple named rows. Each row is identified by a unique Name field. You retrieve rows by name at runtime. Use these for lookup-table style data — country codes, fee schedules, rate tables — where you need many named entries.

Hierarchy Custom Settings

A Hierarchy Custom Setting stores one record at each of three levels: Organization, Profile, and User. The platform automatically resolves the most specific value for the running user. If a User-level record exists, it wins over Profile, which wins over Org. This makes Hierarchy settings ideal for per-user or per-profile feature flags and preferences.

Creating a Hierarchy Custom Setting

  1. Go to Setup → Custom Settings → New.
  2. Set Visibility to Public so Apex across all namespaces can read it.
  3. Select Hierarchy as the Setting Type.
  4. Add your custom fields (Checkbox, Text, Number, etc.).
  5. Manage Default Values from the Custom Setting detail page to set the Org-level record.

Reading a Hierarchy Custom Setting from Apex

The generated class for a Hierarchy Custom Setting exposes a static getInstance() method. It returns the most specific record for the running user with zero SOQL queries.

// Custom Setting: AppConfig__c (Hierarchy type)
// Fields: EnableBetaFeature__c (Checkbox), MaxRetries__c (Number)

public class FeatureService {

    private static final AppConfig__c CONFIG = AppConfig__c.getInstance();

    public static Boolean isBetaEnabled() {
        return CONFIG.EnableBetaFeature__c;
    }

    public static Integer getMaxRetries() {
        return CONFIG.MaxRetries__c != null
            ? CONFIG.MaxRetries__c.intValue()
            : 3;
    }
}

getInstance() with no arguments resolves the hierarchy for the running user automatically. You can also call AppConfig__c.getInstance(userId) or AppConfig__c.getInstance(profileId) to resolve for a specific identity.

Reading a List Custom Setting from Apex

Use getAll() to load every row into a Map<String, SObjectName>, or getInstance(name) to load a single named row. Both are cache-served.

// Custom Setting: ShippingRate__c (List type)
// Fields: RatePerKg__c (Currency), Zone__c (Text)

public class ShippingCalculator {

    private static final Map<String, ShippingRate__c> RATES =
        ShippingRate__c.getAll();

    public static Decimal calculateCost(String zoneName, Decimal weightKg) {
        ShippingRate__c rate = RATES.get(zoneName);
        if (rate == null) {
            throw new IllegalArgumentException('Unknown zone: ' + zoneName);
        }
        return rate.RatePerKg__c * weightKg;
    }
}

Writing to Custom Settings from Apex

Custom Setting records are regular SObject records. You insert, update, and upsert them with DML. This means a running transaction can modify them — useful for test setup or admin utilities.

@IsTest
private class FeatureServiceTest {

    @TestSetup
    static void makeData() {
        AppConfig__c cfg = new AppConfig__c(
            SetupOwnerId = UserInfo.getOrganizationId(),
            EnableBetaFeature__c = true,
            MaxRetries__c = 5
        );
        upsert cfg;
    }

    @IsTest
    static void betaEnabled_returnsTrue() {
        System.assertEquals(true, FeatureService.isBetaEnabled());
    }
}

Setting SetupOwnerId to the org ID creates an Organization-level record. Setting it to a ProfileId or UserId creates the corresponding level.

Custom Metadata Types

Custom Metadata Types (CMTs) look like custom objects but are metadata, not data. Their records ship in change sets and packages, deploy through the Metadata API, and appear in version control alongside your Apex classes. A CMT record is never in the data layer — it lives in the metadata layer.

What That Means in Practice

  • CMT records are included in sandbox refreshes and scratch org definitions automatically.
  • They deploy as part of a package or change set — no separate data migration step.
  • You can query them with SOQL, but those queries do count toward the 100-query limit.
  • DML from Apex is not supported in a running transaction. Updates go through the Metadata API or Tooling API (or declaratively through Setup). The exception is test transactions, where you can use Test.loadData() or construct instances directly.
  • They support relationships to other CMTs and to protected / public visibility per package.

Creating a Custom Metadata Type

  1. Go to Setup → Custom Metadata Types → New.
  2. Give it a plural label and a singular label. The API name gets __mdt suffix automatically.
  3. Add fields. Note: Lookup relationships can only point to other CMTs, not to standard/custom objects.
  4. Go to Manage Records to add records declaratively, or deploy via force:source:deploy.

Querying a Custom Metadata Type from Apex

// Custom Metadata Type: IntegrationEndpoint__mdt
// Fields: BaseUrl__c (Text), Timeout__c (Number), IsActive__c (Checkbox)

public class IntegrationRouter {

    private static final List<IntegrationEndpoint__mdt> ENDPOINTS;

    static {
        ENDPOINTS = [
            SELECT DeveloperName, BaseUrl__c, Timeout__c, IsActive__c
            FROM IntegrationEndpoint__mdt
            WHERE IsActive__c = true
            ORDER BY DeveloperName
        ];
    }

    public static String getUrl(String developerName) {
        for (IntegrationEndpoint__mdt ep : ENDPOINTS) {
            if (ep.DeveloperName == developerName) {
                return ep.BaseUrl__c;
            }
        }
        throw new IntegrationException('No active endpoint: ' + developerName);
    }

    public class IntegrationException extends Exception {}
}

The static initializer runs once per transaction and caches the result in the class variable. You pay one SOQL query per transaction rather than one per call.

Using CMT Records in Tests Without DML

Because you cannot insert CMT records via DML in a live transaction, unit tests use the CMT constructor directly and inject the dependency instead of relying on a static SOQL call.

// Refactored to accept a record list (enables injection in tests)
public class IntegrationRouter {

    @TestVisible
    private static List<IntegrationEndpoint__mdt> endpoints {
        get {
            if (endpoints == null) {
                endpoints = [
                    SELECT DeveloperName, BaseUrl__c, Timeout__c, IsActive__c
                    FROM IntegrationEndpoint__mdt
                    WHERE IsActive__c = true
                ];
            }
            return endpoints;
        }
        set;
    }

    public static String getUrl(String developerName) {
        for (IntegrationEndpoint__mdt ep : endpoints) {
            if (ep.DeveloperName == developerName) {
                return ep.BaseUrl__c;
            }
        }
        throw new IntegrationException('No active endpoint: ' + developerName);
    }

    public class IntegrationException extends Exception {}
}
@IsTest
private class IntegrationRouterTest {

    @IsTest
    static void getUrl_returnsCorrectBaseUrl() {
        // Construct a CMT record directly — no DML needed, no SOQL consumed
        IntegrationEndpoint__mdt mockEp = new IntegrationEndpoint__mdt(
            DeveloperName = 'PaymentGateway',
            BaseUrl__c    = 'https://pay.example.com',
            IsActive__c   = true,
            Timeout__c    = 30
        );
        IntegrationRouter.endpoints = new List<IntegrationEndpoint__mdt>{ mockEp };

        String url = IntegrationRouter.getUrl('PaymentGateway');

        System.assertEquals('https://pay.example.com', url);
    }
}

CMT Relationships

CMTs support Metadata Relationship fields that point to other CMTs. This lets you build structured configuration — for example, a RuleSet__mdt that has a lookup to ValidationConfig__mdt. Query them with a sub-select or a JOIN-style SOQL:

List<ValidationRule__mdt> rules = [
    SELECT DeveloperName, Severity__c, ValidationConfig__r.ErrorMessage__c
    FROM ValidationRule__mdt
    WHERE IsActive__c = true
];

Side-by-Side Comparison

Dimension Custom Settings Custom Metadata Types
Storage layer Data (org database) Metadata (deployment artifact)
Deploys with change set / package No — data migration required Yes
Sandbox refresh behavior May be wiped (depends on refresh type) Always present after refresh
SOQL query limit Exempt (served from cache) Counts toward 100 per transaction
Hierarchy resolution Built-in (Org / Profile / User) Not supported natively
DML from Apex Yes — fully supported No (Metadata API / Setup UI only)
Version control Not natively (data export workaround) Yes — ships as source files
Packaging support (managed) Supported but records not included Fully supported including records
Relationships to standard objects No No (CMT-to-CMT only)
Per-user / per-profile configuration Yes (Hierarchy type) No built-in support

When to Use Which

Choose Custom Settings when

  • The value changes at runtime — for example, an admin toggling a kill switch via a custom UI.
  • You need per-user or per-profile configuration that the platform resolves automatically.
  • You are storing secrets or environment-specific values (API keys, endpoint URLs that differ per org) that should NOT travel in a change set.
  • Your read path is extremely hot and you need the cache exemption on SOQL limits.

Choose Custom Metadata Types when

  • The configuration is part of the application logic and must deploy alongside the code.
  • You need the records to survive sandbox refreshes without manual re-entry.
  • You are building a managed package and want subscribers to be able to extend or override your defaults.
  • The configuration is environment-agnostic — routing rules, validation thresholds, message templates.
  • You want records under version control so changes are code-reviewed and auditable.

A Practical Decision Flowchart

  1. Does the value differ per user or profile at runtime? → Hierarchy Custom Setting.
  2. Does an admin need to change it without a deployment? → Custom Setting (List or Hierarchy).
  3. Must the configuration deploy automatically with your code? → Custom Metadata Type.
  4. Is this a managed package shipping default config to subscribers? → Custom Metadata Type.
  5. Is it a secret or environment-specific credential? → Custom Setting (and ideally Named Credentials for the actual secret).

Real-World Pattern: Combining Both

A common architecture uses CMTs for the application's structural configuration and Custom Settings for the runtime on/off switch. The CMT defines what the integration looks like; the Custom Setting controls whether it runs.

// CMT: defines endpoint details (deploys with code)
// Custom Setting: EnableExternalSync__c controls whether sync is active (admin-toggled)

public class SyncOrchestrator {

    private static final AppConfig__c CONFIG = AppConfig__c.getInstance();

    public void run() {
        if (!CONFIG.EnableExternalSync__c) {
            System.debug('External sync disabled via Custom Setting. Exiting.');
            return;
        }

        List<SyncEndpoint__mdt> targets = [
            SELECT DeveloperName, BaseUrl__c, BatchSize__c
            FROM SyncEndpoint__mdt
            WHERE IsActive__c = true
        ];

        for (SyncEndpoint__mdt target : targets) {
            enqueueSync(target);
        }
    }

    private void enqueueSync(SyncEndpoint__mdt target) {
        // Enqueue a queueable with target details
        System.enqueueJob(new SyncJob(target.BaseUrl__c, target.BatchSize__c.intValue()));
    }
}

The CMT records are in source control and deploy with the application. The Custom Setting flag is flipped by an admin when they want to pause sync — no deployment needed, takes effect immediately.

Common Mistakes

Querying Custom Settings with SOQL

Using SELECT ... FROM AppConfig__c instead of AppConfig__c.getInstance() works but burns a SOQL query and misses hierarchy resolution. Always use the generated static methods for Custom Settings.

// Wrong — costs a query, skips hierarchy
AppConfig__c cfg = [SELECT EnableBetaFeature__c FROM AppConfig__c LIMIT 1];

// Correct — cache-served, hierarchy-resolved
AppConfig__c cfg = AppConfig__c.getInstance();

Putting Deployment-Critical Config in Custom Settings

If your routing rules live in a Custom Setting, a fresh sandbox has no records until someone manually re-enters them. Everything breaks silently. Any configuration the code depends on to function must be a CMT.

Trying DML on CMT Records at Runtime

// This will throw a runtime exception in a live transaction
insert new ValidationRule__mdt(DeveloperName = 'NewRule', IsActive__c = true);
// System.DmlException: DML operation INSERT not allowed on ValidationRule__mdt

CMT records are immutable from Apex in production transactions. Use the Metadata API, the Tooling API, or Setup UI. In test contexts, construct the record and inject it as shown in the test pattern above.

Deployment File Structure for CMTs

In Salesforce DX source format, a CMT and its records look like this on disk:

force-app/
  main/
    default/
      customMetadata/
        IntegrationEndpoint.PaymentGateway.md-meta.xml
        IntegrationEndpoint.ShippingProvider.md-meta.xml
      objects/
        IntegrationEndpoint__mdt/
          IntegrationEndpoint__mdt.object-meta.xml
          fields/
            BaseUrl__c.field-meta.xml
            Timeout__c.field-meta.xml
            IsActive__c.field-meta.xml

Each CMT record is its own .md-meta.xml file. They commit to Git, get code-reviewed, and deploy together with the object definition and your Apex classes in a single sf project deploy start call.

Summary

Custom Settings give you runtime-mutable, cache-served configuration with built-in hierarchy resolution for User, Profile, and Org levels — use them when an admin needs to flip a switch without a deployment, or when values are environment-specific and should not travel between orgs. Custom Metadata Types are metadata records that deploy with your code, survive sandbox refreshes, and live in version control — use them for any configuration that is logically part of the application. The sharpest rule: if losing the records on a sandbox refresh would break the app, the data belongs in a CMT. When both apply, use CMTs for structure and Custom Settings for the runtime on/off control layer on top.

← All articles

Frequently Asked Questions

Do Custom Settings records survive when a sandbox is refreshed from production?

List and Hierarchy Custom Settings records are treated as data, not metadata, so a sandbox refresh wipes them unless you manually re-enter the values or include them in a data deployment. Custom Metadata Type records, by contrast, are deployed as metadata and survive sandbox refreshes automatically. This behavioral difference is one of the most common reasons teams migrate from Custom Settings to Custom Metadata Types.

If AppConfig__c.getInstance() costs zero SOQL queries, does it still count against heap or CPU limits?

The data is served from the application server's memory cache, so it does not consume a SOQL query slot, but the returned SObject does occupy heap space like any other variable. CPU time is also consumed when the platform resolves the hierarchy, though the cost is negligible compared to a real query. For tight-loop scenarios, caching the result in a static variable — as shown with the private static final pattern — avoids even that small overhead on repeat calls.

Can a Hierarchy Custom Setting return null if no record has been configured at any level?

Yes — getInstance() returns null when no record exists at the User, Profile, or Org level, so accessing a field on a null reference throws a NullPointerException at runtime. The safe pattern is to null-check the result of getInstance() before reading fields, or to provide a fallback default inline as shown with the ternary on MaxRetries__c. Setting an Org-level default record in Setup is the simplest way to guarantee getInstance() never returns null.

When should a List Custom Setting be chosen over a Custom Metadata Type for lookup-table data?

A List Custom Setting is preferable when the lookup values must be editable by administrators in production without a deployment — for example, fee schedules or rate tables that change frequently. Custom Metadata Types require a metadata deployment to update records, making them less suitable for data that business users need to modify directly. If the records are stable configuration that should be version-controlled and promoted through environments, Custom Metadata Types are the better choice.