Problem statement: Features that work correctly in sandbox behave differently in production because of differences in metadata, data volume, automation, or integrations, making bugs hard to reproduce and increasing deployment risk.
1 Understand the types of Sandbox vs Prod Drift
Most of your issues will be in 4 buckets:
Metadata drift
Different Flows, Validation Rules, Page Layouts, Triggers, Profiles, Permission Sets, Record Types, etc.
Data drift
Sandbox has tiny, clean data.
Prod has large volume, nulls, weird values, old records.
Automation drift
Different Flows active/inactive
Different scheduled jobs, batch classes running
Different integrations enabled
Environment-specific config
Different endpoints, API keys, Named Credentials, feature toggles.
We’ll attack all 4.
2 Step 1 – Stop “clicking directly” in Production
Hard truth:
If admins/devs change things directly in production (Flows, fields, page layouts) that are not in sandbox → you will have drift.
Policy to adopt
All metadata changes:
Design & build in sandbox
Commit to Git
Deploy to Prod from source/metadata (SFDX, CI, or at least change sets).
Direct production-only changes = strictly forbidden (except hotfix, then back-port to sandbox).
3 Step 2 – Use SFDX + Git to compare sandbox vs prod
You can literally see the differences between orgs.
3.1 Setup SFDX project (once)
`# Create project
sfdx force:project:create -n my-salesforce-project
cd my-salesforce-project
Authorize orgs
sfdx auth:web:login -a DevSandbox
sfdx auth:web:login -a ProdOrg`
3.2 Retrieve metadata from both orgs
Pick important metadata types in manifest/package.xml (ApexClass, ApexTrigger, Flow, ValidationRule via CustomObject, PermissionSet, etc.)
Example manifest/package.xml snippet:
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>*</members>
<name>ApexClass</name>
</types>
<types>
<members>*</members>
<name>ApexTrigger</name>
</types>
<types>
<members>*</members>
<name>Flow</name>
</types>
<types>
<members>*</members>
<name>Profile</name>
</types>
<types>
<members>*</members>
<name>PermissionSet</name>
</types>
<types>
<members>*</members>
<name>CustomObject</name>
</types>
<version>59.0</version>
</Package>
Then:
`# Retrieve from sandbox
sfdx force:source:retrieve -x manifest/package.xml -u DevSandbox -w 10
Copy this folder (or branch) as "prod" version:
git checkout -b sandbox-version
git add .
git commit -m "Sandbox metadata snapshot"
Retrieve from production (in another branch)
git checkout -b prod-version
sfdx force:source:retrieve -x manifest/package.xml -u ProdOrg -w 10
git add .
git commit -m "Production metadata snapshot"`
Now you can:
git diff sandbox-version..prod-version
…and literally see which Flows, Validation Rules, Triggers, Profiles differ.
This shows you where behavior differs.
4 Step 3 – Handle environment-specific configuration with Custom Metadata
Don’t hardcode “sandbox vs prod” in Apex. Use Custom Metadata / Custom Settings.
4.1 Create a Custom Metadata Type (UI)
Name: Environment_Config__mdt
Fields:
Environment_Name__c (Text)
Base_API_URL__c(Text)
Is_FeatureX_Enabled__c (Checkbox)
Create one record per org:
In sandbox:
Environment_Name__c = SANDBOX
Base_API_URL__c = https://sandbox-api.example.com
Is_FeatureX_Enabled__c = true
In prod:
Environment_Name__c = PROD
Base_API_URL__c =https://api.example.com
Is_FeatureX_Enabled__c = false (or as needed)
4.2 Apex helper to read config
`public with sharing class EnvConfigService {
private static Environment_Config__mdt config;
private static Environment_Config__mdt getConfig() {
if (config == null) {
config = [
SELECT Environment_Name__c, Base_API_URL__c, Is_FeatureX_Enabled__c
FROM Environment_Config__mdt
LIMIT 1
];
}
return config;
}
public static String getBaseApiUrl() {
return getConfig().Base_API_URL__c;
}
public static Boolean isFeatureXEnabled() {
return getConfig().Is_FeatureX_Enabled__c;
}
public static String getEnvironmentName() {
return getConfig().Environment_Name__c;
}
}
public with sharing class ExternalSyncService {
4.3 Use it in your logic
public static void sendOrderToExternalSystem(Id orderId) {
String baseUrl = EnvConfigService.getBaseApiUrl();
if (String.isBlank(baseUrl)) {
// Log & skip in misconfigured orgs
System.debug('Base API URL not configured');
return;
}
// callout code here ...
// HttpRequest req = new HttpRequest();
// req.setEndpoint(baseUrl + '/orders/' + orderId);
}
}`
Now:
Sandbox and production can use different endpoints, toggles, etc.
You do NOT need to if/else on System.URL.getOrgDomainUrl() or similar hacks.
5 Step 4 – Align automation (Flows, Scheduled Jobs, Triggers)
A very common source of drift:
Flow active in sandbox, inactive in prod (or vice versa)
Scheduled job running in prod but not sandbox
Old trigger still active in prod but removed/disabled in sandbox
5.1 See scheduled jobs with Apex
Run this in Execute Anonymous (both orgs):
`List jobs = [
SELECT Id, CronJobDetail.Name, State, NextFireTime
FROM CronTrigger
ORDER BY NextFireTime
];
for (CronTrigger ct : jobs) {
System.debug('Job: ' + ct.CronJobDetail.Name +
' | State: ' + ct.State +
' | NextFire: ' + ct.NextFireTime);
}`
Compare sandbox vs production list → you’ll see which scheduled jobs differ.
5.2 Flow & Trigger status
These are metadata; best is to compare via SFDX / Git (Step 2). Key things:
For Flows:
Same versions should be Active in both orgs.
For Apex Triggers:
Same triggers should be Active/Inactive consistently.
If sandbox has a newer active Flow version than prod, then of course behavior differs.
6 Step 5 – Use realistic data sets (data volume + edge cases)
In sandbox, things work with 10 test records. In prod, there are 1M records, null fields, weird values.
Solution: seed sandbox with better data.
Option A – Use Full / Partial sandbox
Prefer at least one Partial or Full sandbox where data is closer to prod.
Option B – Seed data via script or test factory
Small Apex script to create “heavy” data scenario:
`// Execute Anonymous in sandbox
Account parent = new Account(Name = 'Parent Account');
insert parent;
List manyAccs = new List();
for (Integer i = 0; i < 5000; i++) {
manyAccs.add(new Account(
Name = 'Test Account ' + i,
ParentId = parent.Id,
Industry = (i % 2 == 0) ? 'Banking' : null // some nulls
));
}
insert manyAccs;`
This will help reveal performance or logic issues that only appear at scale.
7 Step 6 – Add environment-aware logging to debug differences
When behavior differs, you want quick visibility in prod without relying only on debug logs.
7.1 Create a simple Log__c object
Custom Object: Log__c
Fields:
Level__c (Picklist: INFO, WARN, ERROR)
Location__c (Text) – class/method name
Message__c (Long Text)
Context__c (Long Text) – JSON of important variables
Environment__c (Formula) referencing config or OrganizationType
7.2 Apex logging helper
`public with sharing class LogService {
public static void info(String location, String message, String contextJson) {
insertLog('INFO', location, message, contextJson);
}
public static void error(String location, String message, String contextJson) {
insertLog('ERROR', location, message, contextJson);
}
private static void insertLog(String level, String location, String message, String contextJson) {
try {
Log__c log = new Log__c(
Level__c = level,
Location__c = location,
Message__c = message,
Context__c = contextJson
);
insert log;
} catch (Exception e) {
// Avoid recursive logging failures
System.debug('Failed to insert log: ' + e.getMessage());
}
}
}`
7.3 Use it in critical code paths
`public with sharing class OrderService {
public static void processOrder(Id orderId) {
try {
Order__c ord = [
SELECT Id, Status__c, Amount__c
FROM Order__c
WHERE Id = :orderId
];
LogService.info(
'OrderService.processOrder',
'Starting order processing',
JSON.serialize(ord)
);
// ... processing logic ...
} catch (Exception e) {
LogService.error(
'OrderService.processOrder',
'Error while processing order: ' + e.getMessage(),
'{"orderId":"' + String.valueOf(orderId) + '"}'
);
throw e;
}
}
}`
Now you can compare logs from sandbox vs prod to see exactly where behavior diverges.
8 Step 7 – Add a “pre-deployment checklist” for drift
Before any deploy:
Metadata alignment
SFDX diff for key components: Flows, Triggers, Classes, Validation Rules, Permission Sets.
Automation alignment
Same scheduled jobs?
Same active Flow versions?
Environment config
Custom Metadata / Named Credentials set correctly?
Smoke tests
Run a small script in sandbox and prod (read/write a test record, call critical logic) and compare behavior/logs.
Even a simple Apex smoke test method helps:
`@IsTest
private class SmokeTest {
@IsTest
static void testCoreLogic() {
// create data, call main service methods, assert no exceptions
}
}`
Run this in every org.
Conclusion – Controlling Sandbox vs Production Drift
Your problem isn’t random:
Features behave differently in sandbox and production when metadata, data, automation, and environment config drift apart.
To fix this reliably:
Centralize metadata in Git + SFDX and deploy from source, not random clicks.
Use Custom Metadata/Settings for environment-specific URLs, flags, and config.
Keep Flows, Triggers, and scheduled jobs aligned using metadata diffs and org comparisons.
Seed sandbox with realistic data to catch scale and null issues earlier.
Add logging + a pre-deployment checklist so you can see and prevent differences before they reach users.
Top comments (0)