How to Manage Apex Triggers During Bulk Salesforce Data Loads
Learn how to safely manage Apex triggers during bulk Salesforce data loads — bypass frameworks, batch sizing, bulkification, and post-load validation.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
You're pushing 5 million historical records into Salesforce. You configure your ETL tool, map the fields, and kick off the Bulk API job. Ten minutes later, your integration logs are red: System.LimitException: Apex CPU time limit exceeded, UNABLE_TO_LOCK_ROW, and CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY.
This happens because Salesforce is a multi-tenant environment with strict per-transaction governor limits. When you insert records in bulk, every active Apex trigger, Record-Triggered Flow, validation rule, and workflow rule fires for every batch. If your automation isn't designed for bulk operations, or if your data has relational skew, the load fails.
The fix is not "disable all triggers." The fix is managing the full automation topology on each object — deciding what must run, what can be bypassed for the migration user, and what gets post-processed after the load. Here is the technical blueprint.
Why Bulk Loads Expose Trigger Problems
Whether you use Data Loader, an ETL tool, or a custom script, Salesforce processes bulk data in chunks. For the SOAP API, each batch sends up to 200 records. For the Bulk API, chunks can be up to 10,000 records — but triggers still fire in 200-record increments within each chunk. A 20,000-record load means roughly 100 trigger invocations. (developer.salesforce.com)
Three patterns cause nearly every trigger failure during bulk loads:
- SOQL inside a loop. If your trigger queries the database inside a
forloop iterating overTrigger.new, you'll hit the 100-SOQL-query limit before the batch finishes. With 200 records, that's 200 queries — double the limit. - DML inside a loop. Each
insert,update, ordeletestatement inside a loop consumes one of the 150 DML operations allowed per synchronous transaction. A trigger updating records one at a time fails on any batch larger than 150. - Recursive trigger execution. When a trigger performs DML that fires the same trigger again, limit consumption compounds exponentially. Without a guard, the transaction burns through governor limits before it finishes.
Governor limit exceptions (System.LimitException) cannot be caught with try-catch blocks. When you hit the limit, the entire transaction rolls back — including every record in that batch.
Salesforce Order of Execution and Governor Limits
Every record save in Salesforce triggers a sequence of roughly 20 steps, all sharing a single governor limit pool. The steps most relevant to data loads:
- System validation (required fields, field lengths)
- Before-save record-triggered flows
- Before triggers (Apex
before insert,before update) - Custom validation rules
- Duplicate rules
- Record saved to database (not committed yet)
- After triggers (Apex
after insert,after update) - Assignment rules, auto-response rules
- Workflow rules (field updates here re-fire before/after triggers)
- After-save record-triggered flows
- Roll-up summary calculations (can cascade parent record saves)
- Database commit
A complex org with before triggers, after triggers, three flows, and two workflow rules can consume 60+ SOQL queries per batch before your migration logic even runs.
If multiple Apex triggers exist on the same object for the same event, Salesforce does not guarantee the order in which they fire. Salesforce's architecture guidance explicitly recommends one Apex trigger per object. (architect.salesforce.com)
Governor Limits That Matter
| Limit | Synchronous | Asynchronous (Batch Apex) |
|---|---|---|
| SOQL queries | 100 | 200 |
| DML statements | 150 | 150 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| Callouts | 100 | 100 |
| Records returned by SOQL | 50,000 | 50,000 |
Data Loader batch sizes:
- SOAP API: 200 records per batch (configurable: 1–200)
- Bulk API: up to 10,000 records per batch, but triggers fire in 200-record increments within each chunk
Each batch gets a fresh governor limit allocation. Smaller batches give your triggers more headroom per record, but they do not fix non-bulkified code.
Choosing a Strategy: Decision Framework
There's no single right answer. The strategy depends on what each trigger does, whether its logic must apply to migrated data, and how much risk you can accept.
| Approach | When it fits | Main downside |
|---|---|---|
| Bypass by Custom Permission | Planned migration through a dedicated integration user | Must wire the bypass through Apex, Flows, and validation rules |
| Keep triggers active | Logic is required for record correctness and handler is bulk-safe | Slower loads, higher CPU and lock pressure |
| Deploy trigger as Inactive | Legacy org with no safe runtime bypass, or emergency | Org-wide blast radius and deployment overhead |
Does the trigger logic need to apply to migrated data?
├── YES → Is the trigger bulkified?
│ ├── YES → Keep active, use batch size 200, monitor limits
│ └── NO → Fix the trigger first, then keep active
└── NO → Does the trigger have a bypass switch?
├── YES → Enable bypass for migration user, run load, revert
└── NO → Deploy trigger as Inactive via SF CLI, run load, redeploy as Active
The decision is always data-specific. Each trigger needs to be evaluated against the shape and completeness of the incoming data.
Strategy 1: Custom Permission Bypass
Best for: Planned migrations where you control trigger code and need per-user granularity without affecting the rest of the org.
Current Salesforce architecture guidance recommends driving bypass logic with Custom Permissions checked via FeatureManagement.checkPermission(). Custom Permissions can be assigned through Permission Sets, scoped to a single user, and audited like any other permission assignment. (help.salesforce.com)
Step 1: Create a Dedicated Integration User
Run the migration through its own user. Do not run large imports as a human admin who is also editing records in the UI. A dedicated user makes debug logs, permission scope, and rollback analysis much cleaner. Salesforce's API guidance explicitly recommends a dedicated user per external application. (developer.salesforce.com)
Step 2: Create the Custom Permission and Permission Set
Navigate to Setup → Custom Permissions and create a permission called Bypass_Apex_Triggers. Create a Permission Set called Data_Migration_Bypass, add the Custom Permission to it, and assign it to your integration user.
Step 3: Implement the Bypass Check in Your Trigger Handler
trigger AccountTrigger on Account (
before insert, before update,
after insert, after update
) {
if (FeatureManagement.checkPermission('Bypass_Apex_Triggers')) {
return;
}
AccountTriggerHandler.run(Trigger.operationType, Trigger.new, Trigger.oldMap);
}
public class AccountTriggerHandler {
public static void run(
System.TriggerOperation op,
List<Account> newList,
Map<Id, Account> oldMap
) {
if (op == System.TriggerOperation.BEFORE_INSERT) {
processAccountDefaults(newList);
return;
}
if (op == System.TriggerOperation.AFTER_INSERT) {
createRelatedRecords(newList);
}
}
private static void processAccountDefaults(List<Account> records) {
// Bulkified logic here
}
private static void createRelatedRecords(List<Account> records) {
// Bulkified logic here
}
}The trigger exits in microseconds for your integration user. Regular users don't have the permission, so their transactions process normally.
Alternative: Hierarchy Custom Settings
Some teams prefer a Hierarchy Custom Setting (Bypass_Settings__c) with checkbox fields for each trigger. The advantage: Bypass_Settings__c.getInstance() uses cached reads that do not count against SOQL limits, and you can toggle the bypass in Setup UI without a deployment. The downside: Custom Settings are less auditable than Permission Sets and easier to forget to revert.
Bypass_Settings__c.getInstance() is free in terms of governor limits — unlike querying Custom Metadata Types with a Get Records element in Flow, which does count. If you need the bypass check to be as lightweight as possible, Custom Settings have a slight edge.
Trade-offs of the bypass approach:
- ✅ User-level granularity — doesn't affect other users
- ✅ No downtime for the org
- ✅ Auditable through Permission Set assignments
- ❌ Requires the bypass check to already exist in every trigger handler
- ❌ If someone forgets to revoke the permission after migration, triggers stay bypassed
Bypassing the Full Automation Stack
Apex triggers are only part of the equation. If you only bypass Apex, before-save record-triggered flows still run — and they execute before your Apex before triggers in the order of execution. (help.salesforce.com)
Record-Triggered Flows: Reference the same Custom Permission in the Flow's start criteria:
- Open the Record-Triggered Flow.
- In the Start element, add an entry condition:
- Resource:
$Permission.Bypass_Apex_Triggers - Operator:
Equals - Value:
$GlobalConstant.False
- Resource:
The flow only executes if the user does not have the bypass permission. The flow interview is never created in the first place, saving CPU time entirely.
Validation Rules: Add the same check to your validation formula:
AND(
NOT($Permission.Bypass_Apex_Triggers),
<your existing validation criteria>
)Legacy Automation: Workflow Rules and Process Builder are past Salesforce's December 31, 2025 end-of-support date, but existing automations still run in many orgs. A legacy org can fail a load because of automation nobody planned around. (help.salesforce.com)
Use one bypass permission name per object or automation family and use it everywhere that object can be blocked: Apex triggers, record-triggered flows, validation rules, and custom invocable actions. Mixed control paths are where migrations get messy.
Strategy 2: Keep Triggers Active and Bulkify
Best for: Triggers that must run on migrated data — ownership assignment, rollup calculations, compliance tagging, or enrichment that the source data doesn't include.
Sometimes you want the trigger to fire. The data needs the logic. In that case, the work is making the trigger survive the load.
Bulkify the Trigger Code
// ❌ Non-bulkified — queries and DML inside a loop
trigger OpportunityTrigger on Opportunity (after insert) {
for (Opportunity opp : Trigger.new) {
Account a = [SELECT Id, Industry FROM Account WHERE Id = :opp.AccountId];
a.Description = 'Has opportunities';
update a;
}
}
// ✅ Bulkified — single query, single DML
trigger OpportunityTrigger on Opportunity (after insert) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
accountIds.add(opp.AccountId);
}
List<Account> accountsToUpdate = new List<Account>();
for (Account a : [SELECT Id, Industry FROM Account WHERE Id IN :accountIds]) {
a.Description = 'Has opportunities';
accountsToUpdate.add(a);
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}The bulkified version uses one SOQL query and one DML statement regardless of batch size.
Reduce Batch Size
If triggers consume too many resources per batch, lower the batch size in Data Loader. Start at 200 (SOAP API default), drop to 100 or 50, and test. Each batch gets fresh governor limits. If reducing batch size doesn't help, the problem is non-bulkified code — fix the trigger.
For very large loads (millions of records) where triggers must fire, consider writing a Batch Apex job instead of using Data Loader. Batch Apex gets 200 SOQL queries and 60 seconds of CPU time per execute() invocation.
Add a Recursion Guard
Prevent triggers from re-firing on the same records within a transaction:
public class TriggerRecursionGuard {
private static Set<Id> processedIds = new Set<Id>();
public static Boolean hasBeenProcessed(Id recordId) {
return processedIds.contains(recordId);
}
public static void markProcessed(Set<Id> ids) {
processedIds.addAll(ids);
}
}Use a Set<Id> instead of a single static Boolean. A Boolean flag blocks the trigger entirely on re-entry, which breaks legitimate cascading updates. An ID-based guard only blocks re-processing of the same records.
A single static Boolean is a bad bulk-load guard. Salesforce documents that static variables aren't reset across multiple trigger invocations within the same Bulk API request. A flag flipped on the first chunk affects all later chunks in the same load. (resources.docs.salesforce.com)
Strategy 3: Deactivate Triggers via Metadata Deployment
Best for: Legacy orgs with triggers that don't have bypass logic built in, or when you need a guaranteed bypass with no code path that can accidentally execute.
Salesforce does not provide a UI toggle for trigger activation in production. You must deploy metadata. (help.salesforce.com)
<?xml version="1.0" encoding="UTF-8"?>
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>67.0</apiVersion>
<status>Inactive</status>
</ApexTrigger>sf project deploy start --source-dir force-app/main/default/triggers/AccountTrigger.trigger-meta.xml --target-org productionAfter the load, change status back to Active and redeploy.
Deactivating a trigger affects every user in the org. Any records saved by real users while the trigger is inactive will permanently miss that trigger logic. Schedule deactivation during maintenance windows only.
Trade-offs:
- ✅ Guaranteed bypass — works for any trigger you own
- ❌ Requires two deployments to production
- ❌ Org-wide blast radius — all users lose trigger logic during the window
- ❌ Managed package triggers usually cannot be deactivated
Managed Package Triggers
If your org includes managed packages (Salesforce CPQ, Conga, nCino), those packages install their own triggers on standard objects. You usually cannot deactivate managed package triggers through metadata deployment — the ISV controls the namespace.
Some packages expose bypass mechanisms. Salesforce CPQ provides SBQQ.TriggerControl.disable() to suppress CPQ trigger execution. (developer.salesforce.com) Other packages expose Custom Settings or feature flags.
If the package doesn't offer a bypass:
- Contact the ISV and ask for a bypass flag
- Use smaller batch sizes to stay within limits
- Restructure your load order to avoid triggering the package logic (e.g., load parent records before children to avoid lookup validation)
If the load blocker lives in package code and the package gives you no supported bypass, that is a vendor constraint — not something to improvise around on cutover day.
Row Locks, Data Skew, and Concurrency
Even with all automation bypassed, your bulk load can fail due to database contention. The most common error: UNABLE_TO_LOCK_ROW.
When you insert or update a child record (Contact, Opportunity), Salesforce places a lock on the parent record (Account) to maintain data integrity. If you're using the Bulk API in Parallel Mode, multiple threads process chunks simultaneously. When two threads try to insert child records linked to the same parent, one thread locks the parent and the other waits. If the first thread takes too long, the second times out.
The Data Skew Trap
Imagine migrating 50,000 Contacts where 10,000 are assigned to a generic "Unknown Account." In Parallel Mode, Thread A tries to insert 200 Contacts linked to "Unknown Account." Thread B simultaneously tries to insert a different 200 Contacts linked to the same parent. Thread A locks the record. Thread B waits, then times out: UNABLE_TO_LOCK_ROW.
How to Prevent Row Locks
- Sort your CSV by parent ID. Before uploading child records, sort by
AccountId. This groups all children of the same parent into the same chunk, preventing threads from contending for the same lock. - Switch to Serial Mode. Force the Bulk API to process chunks sequentially. Slower, but eliminates lock contention.
- Resolve data skew. Don't assign more than 10,000 child records to a single parent. Split generic "System Account" records into "System Account 1," "System Account 2," etc.
- Load parents first, then children. Cross-object sequencing is your responsibility. (developer.salesforce.com)
Bulk API 1.0 vs. Bulk API 2.0
The API you choose determines concurrency, chunking behavior, and failure modes.
| Bulk API 2.0 | Bulk API 1.0 | |
|---|---|---|
| Chunking | Automatic | Manual (you set batch size) |
| Processing mode | Parallel only | Parallel or Serial |
| Best for | Large, straightforward loads with stable automation | Complex orgs, data skew, lock-prone objects |
| Batch ordering | Not guaranteed | Serial mode preserves order |
| Failure behavior | Successful batches commit even if others fail | Configurable |
Bulk API 2.0 is Salesforce's recommended default for large loads. It handles batching internally and is the current focus for platform enhancements. But it's parallel-only, with no guaranteed processing order and no SLA for job completion. (developer.salesforce.com)
For complex, heavily customized orgs — especially when loading child records with data skew — Bulk API 1.0 is often safer. It lets you manually control the batch size and enforce Serial processing.
# Bulk API 2.0: default for large, straightforward loads
sf data upsert bulk --sobject Account --file accounts.csv --external-id Legacy_Id__c --target-org prod
# Bulk API 1.0 serial mode: for lock-contention cases
sf force data bulk upsert --sobject Account --file accounts.csv --external-id Legacy_Id__c --serial --target-org prodFor small final-delta loads under ~2,000 records, synchronous REST or SOAP calls often give you tighter operational control than either Bulk API variant. (developer.salesforce.com)
Be careful with triggers that enqueue Queueables, Batch Apex, or platform events per chunk. Asynchronous work launched from record-triggered automation complicates error handling and multiplies governor-limit pressure during heavy loads. (architect.salesforce.com)
Pre-Migration Trigger Audit
Before running any bulk load, inventory every trigger and automation on the target objects. This is where teams either save themselves or create a weekend incident.
Query active triggers:
SELECT Name, TableEnumOrId, Status, Body
FROM ApexTrigger
WHERE Status = 'Active'Full checklist:
- Record-triggered flows on each object (Setup → Flows)
- Validation rules and which ones can be bypassed
- Workflow rules and Process Builders (retired but still firing in many orgs)
- Roll-up summary fields (parent updates cascade trigger executions)
- Managed package triggers
- Triggers that enqueue async work (Queueables, Batch Apex, platform events)
For each object, classify automation into three buckets:
- Keep live: Logic required for immediate record validity — normalizing keys, populating required defaults, stamping fields that downstream loads depend on
- Bypass during load: User-facing or expensive logic — email alerts, Chatter posts, outbound callouts, enrichment that can be recomputed later, triggers that call external APIs
- Post-process after load: Derived data computed safely in batch — backfilling relationships, recalculating rollups, re-running enrichment on loaded history
Test with a 500-record sample before running the full load. We've seen teams skip this step and spend three days debugging limit exceptions that a 30-minute audit would have caught.
For a broader migration planning framework, see our data migration checklist.
Deferring Sharing Calculations
If you're loading millions of records into an org with a Private sharing model, sharing rule recalculations can bring the system to a halt. Every insert triggers a recalculation of who has access based on Role Hierarchy, Sharing Rules, and Manual Sharing.
For massive migrations, defer these calculations until the load is complete:
- Go to Setup → Defer Sharing Calculations.
- Click Suspend for Group Membership Calculations.
- Click Suspend for Sharing Rule Calculations.
- Run your data load.
- Once verified, click Resume or Recalculate.
Deferring sharing calculations means users won't gain access to newly created records until the recalculation completes. Only do this during maintenance windows.
Post-Load Validation and Cleanup
Bypassing triggers means bypassing business logic. If a trigger normally populates a Region__c field based on BillingState, those migrated records will have blank Region__c values unless your ETL replicated that logic. Re-enabling automation does not back-process records you already loaded. If you deferred logic, plan the explicit post-load batch. (developer.salesforce.com)
Post-load checklist:
- Remove the bypass immediately. Revoke the Permission Set or delete the user-level Custom Setting record. Set a calendar reminder.
- Run retroactive logic. Write a Batch Apex class that queries migrated records (using a flag field like
Migration_Source__cor an External ID) and performs the operations the bypassed triggers would have executed. - Verify record counts. Ensure source counts match target counts.
- Check for orphans. Run SOQL queries to find child records with null lookup fields.
- Spot-check calculated fields. Sample records and verify that trigger-derived fields were correctly populated — either by the ETL or the post-processing batch.
- Verify child record creation. If triggers normally create related records on insert (default Contacts on Account insert), confirm those children exist.
- Check sharing and ownership. Triggers that set
OwnerIdor create sharing rules will have been skipped. Run an ownership audit. - Test with a live record. Create a single record through the UI to confirm triggers fire normally again.
For the broader migration validation process, see our CRM migration checklist. If you're building the runbook from scratch, pair this trigger plan with a migration playbook.
Precision Over Brute Force
Trigger management during bulk loads comes down to one question: does this trigger need to run on the incoming data?
If yes, make sure the code is bulkified, set an appropriate batch size, and test with a realistic sample. If no, use a Custom Permission bypass scoped to your integration user — it's the safest, least disruptive approach. Deactivating triggers via metadata deployment is the nuclear option: effective, but it affects the entire org and requires a maintenance window.
The difference between a clean load and a weekend of debugging is almost always the 30 minutes you spend auditing triggers and automation beforehand. If your current answer to "how do we load this safely?" is "we'll just turn the triggers off," you're not ready yet. The safer answer is: we know which automations must stay live, which can be bypassed by a dedicated integration user, which API mode fits the object's lock profile, and how we'll post-process and validate anything we defer.
Frequently Asked Questions
- How do I bypass Apex triggers during a Salesforce data migration?
- The safest method is a Custom Permission checked via FeatureManagement.checkPermission() at the top of every trigger handler, assigned to a dedicated integration user through a Permission Set. This skips trigger logic for the migration user without affecting anyone else. Alternatively, a Hierarchy Custom Setting with bypass checkboxes works and can be toggled in Setup UI without a deployment. Deactivating triggers via metadata deployment is a last resort since it disables logic org-wide.
- Why do Apex triggers fail during bulk data loads?
- Triggers share a per-transaction governor limit pool with all other automations. A Data Loader batch sends up to 200 records through a trigger at once. If the trigger has SOQL queries or DML statements inside loops, it will exceed the 100-query or 150-DML limit and throw a System.LimitException that cannot be caught with try-catch.
- Should I use Bulk API 1.0 or Bulk API 2.0 for data migration?
- Use Bulk API 2.0 as the default for large, straightforward loads — it handles batching automatically and is Salesforce's current recommended API. Use Bulk API 1.0 when you have data skew, complex triggers, or lock-contention issues, because it allows manual batch sizing and Serial processing mode.
- Is a static Boolean safe for bypassing triggers in Bulk API loads?
- No. Salesforce documents that static variables aren't reset across multiple trigger invocations within the same Bulk API request. A flag flipped on the first chunk affects all later chunks. Use a user-scoped Custom Permission bypass and record-level idempotency (Set
) instead of a static Boolean. - Can I deactivate managed package triggers during a data load?
- Usually not — managed package triggers are controlled by the ISV and can't be deactivated via metadata deployment. Some packages like Salesforce CPQ offer built-in bypass methods (e.g., SBQQ.TriggerControl.disable()). Check the package documentation or contact the vendor for a supported bypass mechanism.