Custom Metadata in Salesforce: Configuration That Survives Deployments
Every production org has configuration values scattered somewhere — tax rates hardcoded in Apex, feature flags buried in Custom Settings, environment-specific URLs copied manually between sandboxes. Each approach works until it breaks at the worst moment: a deployment that wipes runtime data, a sandbox refresh that loses configuration, or a governor limit hit because you queried a Custom Setting with getInstance() inside a loop.
Custom Metadata Types solve all three problems. They are metadata, not data, which means they deploy with your package, survive sandbox refreshes, can be referenced in formula fields and validation rules without SOQL, and count toward zero governor limits when accessed through the framework cache. This tutorial walks through everything you need to build production-grade configuration management with Custom Metadata.
What Custom Metadata Types Actually Are
A Custom Metadata Type (CMT) is a schema definition — like a custom object — but whose records are metadata records stored in your org's metadata layer rather than in the database. The distinction matters enormously:
- CMT records are packaged and deployed via change sets, SFDX, or the Metadata API — they travel with your code.
- They survive sandbox refreshes. Custom Object records and Custom Setting data do not.
- They are readable in formula fields, validation rules, and Process Builder/Flow without SOQL.
- Apex access uses the
getInstance()andgetAll()methods on the generated type class — no SOQL, no query rows consumed. - They are read-only at runtime in managed packages, making them safe for ISVs to ship locked configuration.
What they are not: a replacement for transactional data. You cannot insert or update CMT records through normal DML in Apex (only through the Metadata API or Tooling API). If your configuration changes frequently at runtime, stick to Custom Settings or custom objects.
Custom Metadata vs. Custom Settings vs. Custom Objects
Understand the tradeoffs before picking a tool:
- Custom Metadata Types — deployable config, survives refreshes, no SOQL cost, read-only via DML. Best for: feature flags, routing rules, rate tables, integration endpoints, threshold values.
- Custom Settings (Hierarchy/List) — data-layer config, supports runtime DML, hierarchy type allows per-profile/per-user overrides. Best for: user-specific toggles, values that ops teams change without a deployment.
- Custom Objects — full transactional records, full DML, counts against SOQL governor limits. Best for: config that needs an audit trail, approval process, or complex relationships.
The single strongest argument for CMT: when you refresh a sandbox, all your Custom Setting data is gone unless you have a separate data migration script. CMT records come with the refresh because they are part of the org metadata snapshot.
Creating a Custom Metadata Type
Navigate to Setup → Custom Metadata Types → New. For this tutorial, we will build a discount tier configuration used by an order pricing engine.
- Set Label to
Discount Tier. The plural label becomesDiscount Tiers. - The API Name auto-fills as
Discount_Tier__mdt. The__mdtsuffix is mandatory and distinguishes CMTs from custom objects (__c). - Save the type, then click New Field to add fields to the schema.
Add the following fields to Discount_Tier__mdt:
Min_Order_Amount__c— Currency, requiredMax_Order_Amount__c— Currency, requiredDiscount_Percentage__c— Number (5, 2), requiredIs_Active__c— Checkbox, default truePriority__c— Number (3, 0) — used to resolve overlapping ranges
Every CMT record automatically has two system fields: DeveloperName (API name of the record) and MasterLabel (the human-readable label). You will use DeveloperName as a stable key in Apex.
Creating Custom Metadata Records
After saving the type, click Manage Discount Tiers → New to create records. These are the configuration entries your Apex code will read.
Create three records:
- Label: Bronze Tier | DeveloperName:
Bronze_Tier| Min: 0 | Max: 500 | Discount: 5 | Priority: 1 - Label: Silver Tier | DeveloperName:
Silver_Tier| Min: 500 | Max: 2000 | Discount: 10 | Priority: 2 - Label: Gold Tier | DeveloperName:
Gold_Tier| Min: 2000 | Max: 999999 | Discount: 15 | Priority: 3
Reading Custom Metadata in Apex
Salesforce auto-generates a typed Apex class for every CMT named after its API name. For Discount_Tier__mdt, that class is Discount_Tier__mdt with static methods getInstance(developerName) and getAll().
Fetching a Single Record by Developer Name
Discount_Tier__mdt bronze = Discount_Tier__mdt.getInstance('Bronze_Tier');
System.debug(bronze.Discount_Percentage__c); // 5.0
getInstance() returns null if the record does not exist — always null-check in production code.
Fetching All Records
Map<String, Discount_Tier__mdt> allTiers = Discount_Tier__mdt.getAll();
for (String key : allTiers.keySet()) {
Discount_Tier__mdt tier = allTiers.get(key);
System.debug(tier.MasterLabel + ' — ' + tier.Discount_Percentage__c + '%');
}
getAll() returns a Map<String, SObjectType> keyed by DeveloperName. Neither call consumes SOQL query rows.
SOQL Access — When You Need Filtering
The no-SOQL cache methods return all records or one by name. When you need server-side filtering (e.g., only active tiers), use SOQL. This still works in formula fields for simple field references, but for dynamic filtering SOQL is necessary:
List<Discount_Tier__mdt> activeTiers = [
SELECT MasterLabel, DeveloperName,
Min_Order_Amount__c, Max_Order_Amount__c,
Discount_Percentage__c, Priority__c
FROM Discount_Tier__mdt
WHERE Is_Active__c = true
ORDER BY Priority__c ASC
];
SOQL on CMT is identical in syntax to SOQL on custom objects. It does consume query rows, so prefer getAll() with in-memory filtering when the full record set is small (under a few hundred records).
Building the Pricing Engine
This class applies the correct discount tier to an order amount using only cached metadata — no SOQL in the execution path:
public class OrderPricingService {
// Loaded once per transaction; no SOQL cost
private static final Map<String, Discount_Tier__mdt> TIER_MAP =
Discount_Tier__mdt.getAll();
public static Decimal applyDiscount(Decimal orderAmount) {
if (orderAmount == null || orderAmount <= 0) {
return orderAmount;
}
Discount_Tier__mdt matchedTier = resolveTier(orderAmount);
if (matchedTier == null) {
return orderAmount;
}
Decimal multiplier = 1 - (matchedTier.Discount_Percentage__c / 100);
return orderAmount * multiplier;
}
public static String getTierName(Decimal orderAmount) {
Discount_Tier__mdt tier = resolveTier(orderAmount);
return tier != null ? tier.MasterLabel : 'No Tier';
}
private static Discount_Tier__mdt resolveTier(Decimal amount) {
Discount_Tier__mdt bestMatch = null;
Integer highestPriority = -1;
for (Discount_Tier__mdt tier : TIER_MAP.values()) {
if (!tier.Is_Active__c) { continue; }
Boolean inRange = (amount >= tier.Min_Order_Amount__c
&& amount < tier.Max_Order_Amount__c);
if (inRange && tier.Priority__c > highestPriority) {
bestMatch = tier;
highestPriority = (Integer) tier.Priority__c;
}
}
return bestMatch;
}
}
Because TIER_MAP is a static final field initialized from getAll(), it is populated exactly once per transaction regardless of how many times applyDiscount() is called in a bulk context.
Unit Testing With Custom Metadata
A common frustration: CMT records created in Setup are visible in unit tests by default. Unlike Custom Object records, you do not need @isTest(SeeAllData=true) to access them. However, you cannot insert or modify CMT records via DML in test classes.
The clean solution is to inject a List or Map through a testVisible setter, isolating the test from the org's actual configuration:
public class OrderPricingService {
@TestVisible
private static Map<String, Discount_Tier__mdt> tierCache =
Discount_Tier__mdt.getAll();
public static Decimal applyDiscount(Decimal orderAmount) {
if (orderAmount == null || orderAmount <= 0) {
return orderAmount;
}
Discount_Tier__mdt matchedTier = resolveTier(orderAmount);
if (matchedTier == null) { return orderAmount; }
Decimal multiplier = 1 - (matchedTier.Discount_Percentage__c / 100);
return orderAmount * multiplier;
}
private static Discount_Tier__mdt resolveTier(Decimal amount) {
Discount_Tier__mdt bestMatch = null;
Integer highestPriority = -1;
for (Discount_Tier__mdt tier : tierCache.values()) {
if (!tier.Is_Active__c) { continue; }
Boolean inRange = (amount >= tier.Min_Order_Amount__c
&& amount < tier.Max_Order_Amount__c);
if (inRange && tier.Priority__c > highestPriority) {
bestMatch = tier;
highestPriority = (Integer) tier.Priority__c;
}
}
return bestMatch;
}
}
@isTest
private class OrderPricingServiceTest {
private static void loadTestTiers() {
// Construct in-memory CMT records — no DML needed
Discount_Tier__mdt bronze = new Discount_Tier__mdt(
DeveloperName = 'Bronze_Tier',
MasterLabel = 'Bronze Tier',
Min_Order_Amount__c = 0,
Max_Order_Amount__c = 500,
Discount_Percentage__c = 5,
Priority__c = 1,
Is_Active__c = true
);
Discount_Tier__mdt gold = new Discount_Tier__mdt(
DeveloperName = 'Gold_Tier',
MasterLabel = 'Gold Tier',
Min_Order_Amount__c = 2000,
Max_Order_Amount__c = 999999,
Discount_Percentage__c = 15,
Priority__c = 3,
Is_Active__c = true
);
OrderPricingService.tierCache = new Map<String, Discount_Tier__mdt>{
'Bronze_Tier' => bronze,
'Gold_Tier' => gold
};
}
@isTest
static void bronzeTierApplied() {
loadTestTiers();
Decimal result = OrderPricingService.applyDiscount(300);
System.assertEquals(285, result, 'Expected 5% discount on 300');
}
@isTest
static void goldTierApplied() {
loadTestTiers();
Decimal result = OrderPricingService.applyDiscount(5000);
System.assertEquals(4250, result, 'Expected 15% discount on 5000');
}
@isTest
static void noMatchReturnsOriginalAmount() {
loadTestTiers();
// 1500 falls in Silver range — not loaded in test data
Decimal result = OrderPricingService.applyDiscount(1500);
System.assertEquals(1500, result, 'No matching tier — amount unchanged');
}
@isTest
static void nullInputReturnedUnchanged() {
loadTestTiers();
System.assertNull(OrderPricingService.applyDiscount(null));
}
}
Constructing CMT SObjects in memory with new Discount_Tier__mdt(...) is perfectly legal in Apex. You are just building an in-memory SObject instance — no insert operation is attempted.
Using Custom Metadata in Validation Rules and Formula Fields
One of the most underused features: CMT fields are first-class citizens in declarative tools. In a validation rule or formula field, reference a CMT record with the function:
$CustomMetadata.Discount_Tier__mdt.Gold_Tier.Discount_Percentage__c
The syntax is $CustomMetadata.TypeAPIName.RecordDeveloperName.FieldAPIName. This reference is resolved at compile time with zero runtime cost — no SOQL, no Apex. A validation rule could enforce:
AND(
Amount >= $CustomMetadata.Discount_Tier__mdt.Gold_Tier.Min_Order_Amount__c,
Discount_Applied__c < $CustomMetadata.Discount_Tier__mdt.Gold_Tier.Discount_Percentage__c
)
This is only practical when referencing a specific known record. For dynamic lookups (e.g., "find the right tier for this amount"), Apex is required.
Deploying Custom Metadata
CMT types and records deploy as XML metadata files. In an SFDX project the structure is:
force-app/
main/
default/
customMetadata/
Discount_Tier.Bronze_Tier.md-meta.xml
Discount_Tier.Gold_Tier.md-meta.xml
Discount_Tier.Silver_Tier.md-meta.xml
objects/
Discount_Tier__mdt/
Discount_Tier__mdt.object-meta.xml
fields/
Discount_Percentage__c.field-meta.xml
Is_Active__c.field-meta.xml
Max_Order_Amount__c.field-meta.xml
Min_Order_Amount__c.field-meta.xml
Priority__c.field-meta.xml
A CMT record file looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<label>Gold Tier</label>
<protected>false</protected>
<values>
<field>Discount_Percentage__c</field>
<value xsi:type="xsd:double">15.0</value>
</values>
<values>
<field>Is_Active__c</field>
<value xsi:type="xsd:boolean">true</value>
</values>
<values>
<field>Max_Order_Amount__c</field>
<value xsi:type="xsd:double">999999.0</value>
</values>
<values>
<field>Min_Order_Amount__c</field>
<value xsi:type="xsd:double">2000.0</value>
</values>
<values>
<field>Priority__c</field>
<value xsi:type="xsd:double">3.0</value>
</values>
</CustomMetadata>
Deploy it exactly as you deploy Apex classes:
sf project deploy start --source-dir force-app
Both the type definition and the record files deploy together. A subsequent sandbox refresh will still have these records because they were deployed as metadata — not seeded as data.
Protected vs. Unprotected Records
The <protected> flag in the XML (also configurable per record in Setup) controls visibility in managed packages:
- protected = true — the record is hidden from subscribers. They cannot read or reference it from outside your package namespace. Use this to ship internal configuration the subscriber should never override.
- protected = false — the record is visible to subscribers. They can read it, and in some contexts extend or override it. Use this for records you want subscribers to customize.
For non-packaged orgs this distinction is irrelevant — all records are accessible.
Common Patterns and Pitfalls
Pattern: Feature Flag Registry
// FeatureFlag__mdt has: Feature_Name__c (Text), Is_Enabled__c (Checkbox)
public class FeatureFlags {
private static final Map<String, FeatureFlag__mdt> FLAGS =
FeatureFlag__mdt.getAll();
public static Boolean isEnabled(String featureName) {
FeatureFlag__mdt flag = FLAGS.get(featureName);
return flag != null && flag.Is_Enabled__c;
}
}
// Usage
if (FeatureFlags.isEnabled('New_Checkout_Flow')) {
CheckoutServiceV2.process(cart);
} else {
CheckoutService.process(cart);
}
Toggle features for a deployment without code changes by updating the CMT record and deploying only that XML file.
Pattern: Integration Endpoint Registry
// IntegrationConfig__mdt has: Endpoint_URL__c, API_Version__c, Timeout_ms__c
public class IntegrationSettings {
public static IntegrationConfig__mdt get(String systemName) {
IntegrationConfig__mdt config = IntegrationConfig__mdt.getInstance(systemName);
if (config == null) {
throw new IllegalArgumentException(
'No integration config found for: ' + systemName
);
}
return config;
}
}
// Usage
IntegrationConfig__mdt cfg = IntegrationSettings.get('Payment_Gateway');
HttpRequest req = new HttpRequest();
req.setEndpoint(cfg.Endpoint_URL__c + '/charge');
req.setTimeout((Integer) cfg.Timeout_ms__c);
Environment-specific endpoint URLs live in CMT records. Promote them through sandboxes and production via deployment. No manual copying between orgs, no data scripts.
Pitfall: Confusing DeveloperName with MasterLabel
DeveloperName is the stable API name used in code (getInstance('Gold_Tier')). MasterLabel is the display label shown in Setup UI. They can diverge if someone renames the label after creation. Always key your Apex logic on DeveloperName, never on MasterLabel.
Pitfall: Editing CMT Records in Production Directly
Unlike Custom Settings, CMT records can be edited directly in production Setup without a deployment — a convenient escape hatch. Use it sparingly. The moment you edit a record in production without updating the version-controlled XML, your org is out of sync with source control. Treat direct-in-org edits as a break-glass measure and follow up immediately by pulling the updated metadata back into your repo:
sf project retrieve start --metadata "CustomMetadata:Discount_Tier.Gold_Tier"
Pitfall: Querying CMT Inside Loops
Even though CMT SOQL queries are cheap, running any SOQL inside a loop violates governor limit best practices and will fail at 101 queries. Cache at class load time using a static final map, then look up in the loop:
// Wrong
for (Order__c o : orders) {
List<Discount_Tier__mdt> tiers = [SELECT ... FROM Discount_Tier__mdt]; // SOQL in loop
}
// Right
private static final Map<String, Discount_Tier__mdt> TIERS = Discount_Tier__mdt.getAll();
for (Order__c o : orders) {
Discount_Tier__mdt tier = TIERS.get('Gold_Tier'); // Map lookup, zero queries
}
Updating Custom Metadata via Apex (Metadata API)
When you genuinely need to update CMT records at runtime — for example, an admin-facing setup wizard — use the Metadata.DeployContainer API. This is asynchronous and requires the Metadata namespace:
public class CmtUpdater {
public static void updateGoldTierDiscount(Decimal newDiscount) {
Metadata.CustomMetadata record = new Metadata.CustomMetadata();
record.fullName = 'Discount_Tier.Gold_Tier';
record.label = 'Gold Tier';
Metadata.CustomMetadataValue val = new Metadata.CustomMetadataValue();
val.field = 'Discount_Percentage__c';
val.value = newDiscount;
record.values.add(val);
Metadata.DeployContainer container = new Metadata.DeployContainer();
container.addMetadata(record);
Metadata.Operations.enqueueDeployment(container, new CmtDeployCallback());
}
public class CmtDeployCallback implements Metadata.DeployCallback {
public void handleResult(Metadata.DeployResult result,
Metadata.DeployCallbackContext ctx) {
if (!result.done || result.status != Metadata.DeployStatus.Succeeded) {
System.debug('CMT deploy failed: ' + result.errorMessage);
}
}
}
}
This is a genuine metadata deployment — it runs asynchronously and cannot be rolled back with a transaction rollback. Use it only when users need to configure CMT values through the application UI rather than through Setup or deployments.
Summary
Use Custom Metadata Types whenever you have configuration that must travel with your code through environments and survive sandbox refreshes — integration endpoints, feature flags, business rules, rate tables. Access them via getInstance() or getAll() for zero SOQL cost, or via SOQL when you need server-side filtering. Store them in source control as XML and deploy them alongside your Apex — their superpower is that configuration and code deploy atomically as a single unit. For unit tests, inject mock CMT instances via @TestVisible static fields rather than relying on org data. The one thing to remember above all: CMT records are metadata, not data — they move with deployments, they do not move with data migrations, and they must never be treated as a runtime-writable store.
Frequently Asked Questions
Can Custom Metadata records be updated by end users or ops teams without a deployment?
No — Custom Metadata records can only be changed through the Metadata API, Tooling API, or a deployment (change set, SFDX, etc.). This makes them unsuitable for configuration that operations teams need to adjust at runtime; Custom Settings are the better fit for values that change between deployments without developer involvement.
Why don't Custom Metadata queries count against Salesforce governor limits?
Apex accesses Custom Metadata records through generated type classes using getInstance() or getAll(), which pull from the platform's framework cache rather than executing SOQL against the database. Because no query rows are consumed, CMT lookups are safe inside loops and high-volume trigger contexts where a comparable Custom Setting or custom object query would risk hitting the 100-SOQL-per-transaction limit.
What happens to Custom Metadata records when a sandbox is refreshed?
Custom Metadata records survive sandbox refreshes intact because they live in the metadata layer, not the org database. This is the key operational advantage over Custom Settings and custom object records, both of which are wiped during a refresh and must be manually re-entered or scripted back in — a common source of environment drift in multi-sandbox development workflows.
Can Custom Metadata values be used directly inside validation rules or formula fields without writing Apex?
Yes — Custom Metadata records are accessible in formula fields, validation rules, and Flow/Process Builder natively, with no SOQL required. This means business rules that depend on configurable thresholds (e.g., a maximum discount percentage) can reference a CMT record directly in a validation rule formula, keeping the logic declarative and the value deployable.