Apex DML error

FIELD_CUSTOM_VALIDATION_EXCEPTION

"FIELD_CUSTOM_VALIDATION_EXCEPTION" — what it means and how to fix it

Your record was rejected during save by something your org configured: a validation rule whose error condition evaluated to TRUE, or a trigger calling addError(). The error text after the code is your own message. In test suites this is the single most common DML failure, and the cause is nearly always test data rather than the rule.

Quick answer

Read the text between the error code and the bracketed field — that string is the message someone wrote in a validation rule or an addError() call. Search your metadata for it (grep -r "that message" force-app) and you land directly on the rule. Then fix the record you are inserting; disabling the rule is almost never the right move.

What the error means, mechanically

FIELD_CUSTOM_VALIDATION_EXCEPTION is the status code Salesforce assigns when a save is rejected by org-defined logic rather than by the platform's own constraints. Two things produce it:

  • A declarative validation rule. Each active rule on the object has an error condition formula. It is evaluated against the record being saved; if it returns TRUE, the row is rejected with the rule's error message.
  • An Apex trigger calling addError() on the record or on a field. Same status code, message supplied by your code.
text
Insert failed. First exception on row 0; first error:
  FIELD_CUSTOM_VALIDATION_EXCEPTION, Close Date must be in the future: [CloseDate]

The bracketed field is the rule's error display field — where the message would appear in the UI. It is often empty ([]) because the rule was configured to show at the top of the page, or because addError() was called on the record rather than on a field. An empty bracket does not mean "no field involved".

Order matters when debugging: validation rules run after before-save triggers. A before-insert trigger that populates a field can satisfy a rule, and a before-insert trigger that clears one can break it — so the record the rule sees is not always the record your code built.

The causes, ranked by how often they actually happen

1. Test data that satisfies required fields but not the rules

The dominant cause. A test factory populates every nillable=false field — that's what the schema tells it to do — and then a rule rejects the row because the combination of values is invalid. Required-field coverage and rule satisfaction are different problems, and the second one isn't visible in the schema at all.

apex
// Rule on Opportunity: AND(ISPICKVAL(StageName, 'Closed Won'), ISBLANK(Contract_Number__c))
// -> "Closed Won opportunities need a contract number"

// Fails: every required field is set, the combination is invalid
Opportunity o = new Opportunity(
  Name = 'Test Opp',
  StageName = 'Closed Won',
  CloseDate = Date.today()
);
insert o;   // FIELD_CUSTOM_VALIDATION_EXCEPTION

// Passes: build the record in a state the rules accept
Opportunity o = new Opportunity(
  Name = 'Test Opp',
  StageName = 'Prospecting',        // a state with fewer constraints
  CloseDate = Date.today().addDays(30)
);
insert o;

The habit worth forming: factories should create records in the simplest valid state, and let each test move them into the state it is actually testing. A factory that defaults to Closed Won drags every rule about closed opportunities into tests that don't care about them.

2. Rules that depend on data the test doesn't have

Tests run with SeeAllData=false, so anything a rule reads outside the record is absent unless you created it: a lookup's fields via cross-object formula, a Custom Setting used as a feature flag, a Custom Metadata threshold. A rule that reads Account.Region__r.Is_Active__c evaluates against nothing and can flip to TRUE in an empty org.

apex
@testSetup
static void setup() {
  // Custom Settings are not visible under SeeAllData=false — create them
  insert new Feature_Flags__c(Enforce_Credit_Check__c = false);

  // And the related records the rules traverse
  Region__c r = new Region__c(Name = 'EMEA', Is_Active__c = true);
  insert r;
  insert new Account(Name = 'Test Co', Region__c = r.Id);
}

3. A trigger's addError(), not a rule at all

Same status code, entirely different place to look. If the message isn't in any validationRules/*.validationRule-meta.xml, it's in Apex. Both surface identically, which is why searching the whole project rather than just the rules folder is the faster first move.

apex
trigger AccountTrigger on Account (before insert) {
  for (Account a : Trigger.new) {
    if (a.AnnualRevenue != null && a.AnnualRevenue < 0) {
      a.AnnualRevenue.addError('Revenue cannot be negative');
      // -> FIELD_CUSTOM_VALIDATION_EXCEPTION, Revenue cannot be negative: [AnnualRevenue]
    }
  }
}

4. Update-only rules fired by a field your code touched

Rules using ISCHANGED(), PRIORVALUE(), or NOT(ISNEW()) are invisible on insert and fire on the first update. A test that inserts fine and fails on the second DML is usually this — and often the field that "changed" was written by a trigger, not by the test.

5. Rules gated on profile, permission, or user

$Profile.Name, $Permission.X, and $User references make a rule behave differently for different runners. Code that passes as your admin user fails under System.runAs(standardUser) — which is exactly what you wanted the runAs test to tell you, even though it reads like a bug in the test.

Finding the exact rule in under a minute

The error message is the search key. It is verbatim the rule's errorMessage element:

bash
# Searches rules and Apex at once — addError() messages match too
grep -rn "Close Date must be in the future" force-app/

# All active rules on one object, with their formulas
grep -A3 "errorConditionFormula" \
  force-app/main/default/objects/Opportunity/validationRules/*.xml

If the message came from a managed package you can't grep, query the rule metadata through the Tooling API or read it in Setup — but check your own source first; it's faster and it's usually there.

Fixing it: data first, bypass last

Ranked by how much future pain each option creates:

  • Fix the test data. Correct 90% of the time. The rule is enforcing a real business constraint and your fixture was building an impossible record.
  • Fix the code under test. If production code builds the record, the rule just caught a genuine bug before your users did.
  • Make the rule bypassable, deliberately. The standard pattern is a hierarchy Custom Setting checked in the error condition — but adding one only to make a test pass means the test no longer proves the thing it claims to.
apex
// Rule formula: AND(NOT($Setup.Bypass__c.Validation_Rules__c), <real condition>)
@testSetup
static void setup() {
  // Only where the test genuinely isn't about this rule
  insert new Bypass__c(SetupOwnerId = UserInfo.getOrganizationId(),
                       Validation_Rules__c = true);
}

Deactivating the rule in the org to get a suite green is the one option with no upside: it breaks the same tests for everyone else and removes the constraint from production.

Reproduce it locally — rules included

Nimbus runs your Salesforce Apex tests on your machine against an embedded PostgreSQL, and it loads the active validation rules from your project's objects/*/validationRules/ metadata. Error condition formulas are evaluated on insert and update, and a failing rule raises a DmlException carrying FIELD_CUSTOM_VALIDATION_EXCEPTION and the rule's own error message — the same string you'd get from the org. Trigger addError() calls surface the same way.

Precisely: rules marked inactive are not evaluated, and a rule whose formula uses a construct the local formula engine can't evaluate is skipped rather than guessed at — so a rule can pass locally and still fire in the org. Treat a local failure as real, and a local pass on an exotic formula as unproven.

bash
# Reproduce the rejection without a deploy
nimbus test "OpportunityServiceTest.*"

# See the DML row that was rejected and what ran before it
nimbus explain OpportunityServiceTest.testCloseWon

# Generate a factory with required fields already populated
nimbus fixture Opportunity Account --write