This guide walks through 58 Database.Batchable implementations for processing large record volumes safely within governor limits. Each entry below includes the full problem statement, the governor-limit and best-practice constraints it's testing, and a numbered approach to solving it — then you can open the same problem in ApexArena's browser-based Apex editor and get instant pass/fail feedback against real test cases.
Build a Database.Batchable class that finds and merges duplicate
Lead records sharing the same email address.
Keep the most recently created record and merge others into it.
Log results to a custom object Merge_Log__c.
Database.merge() for the actual mergingfinish() method must send a summary emailWrite a Batch Apex class that archives closed Opportunities older than a given number of days.
Requirements:
Database.Batchable<SObject> interfaceInteger daysOld (archive opps closed more than this many days ago)start(): return a Database.QueryLocator for Opportunities where
StageName IN ('Closed Won','Closed Lost') AND
CloseDate < :cutoffDateexecute(): set Archived__c = true on each record in the batch and updatefinish(): send an email to the running user with the batch completion summaryDatabase.Stateful to track total records processed across batchesDatabase.update(scope, false) for partial success1,000 Account records are processed by a Batch Apex job that updates a custom field
Last_Processed__c (DateTime). Some records may fail (e.g., validation rules,
locked records).
Write the batch class so it:
Database.update(records, false) (partial success) instead of the
all-or-nothing DML.Batch_Error_Log__c with fields Record_Id__c and Error_Message__c.finish(), sends an email summarising total records processed and failures.Database.executeBatch() inside Test.startTest()/stopTest().Write a Batch Apex class AccountRevenueTierBatch that reads every Account and
sets a custom field Revenue_Tier__c based on AnnualRevenue:
Database.Batchable<SObject> with all three methods.start(): query Id, AnnualRevenue, Revenue_Tier__c FROM Account.execute(): assign tier, skip records where value did not change, call
Database.update(list, false) outside the loop.finish(): log completion with System.debug.AnnualRevenue as 0 — never call arithmetic on null.if (!toUpdate.isEmpty()) before updating.Database.update(list, false) allows partial success — safe for bulk runs.Write StaleContactDeactivationBatch that marks Contacts as stale
when they have had no activity for 24 months.
Database.Batchable<SObject> and
Database.Stateful.processedCount and
failedCount (both Integer, both global).start(): filter Contacts where Is_Stale__c = false AND
(LastActivityDate < 24 months ago OR LastActivityDate = null).execute(): set Is_Stale__c = true, call
Database.update(list, false), iterate SaveResult to count
successes and failures, log each error with System.debug.finish(): log both counts.Database.Stateful to accumulate counts across batches.Database.SaveResult — never silently swallow errors.Date.today().addMonths(-24).Write ContactTitleSyncBatch that reads all Contacts with an Account
and sets the Contact's Title field based on the parent Account's
Industry.
'Technology Professional''Finance Professional''Healthcare Professional''Industry Professional'Database.Batchable<SObject>.start(): query Contacts (with their Title and the parent Account's Industry via a child-to-parent relationship), restricted to Contacts that actually have a parent Account.execute(): skip Contacts with null Industry; only update when title changes; wrap DML in try-catch(DmlException e).finish(): log with System.debug.Account.Industry) — avoids a second query.continue — defensive coding.Write AccountEngagementScoreBatch that calculates a custom
Engagement_Score__c for every Account using three signals, and logs
all errors via Database.Stateful.
AnnualRevenue ≥ 1,000,000NumberOfEmployees ≥ 100Database.Batchable<SObject> and Database.Stateful.global Integer successCount, global Integer errorCount,
global List<String> errorLog.start(): include a parent-to-child subquery that checks for the mere
existence of at least one related Contact, without pulling back more than needed.execute(): wrap per-record scoring in try-catch(Exception e);
call Database.update(list, false); inspect every
Database.SaveResult.finish(): log counts and every entry in errorLog.Write a batch class StaleOpportunityBatch that marks all
Prospecting Opportunities that were created more than
365 days ago as Closed Lost.
Database.Batchable<SObject>.start() — return a Database.QueryLocator for Opportunities that
are still in the Prospecting stage, not yet closed, and were created on or before a
cutoff date (cutoff = today minus 365 days).execute() — set StageName = 'Closed Lost' and
CloseDate = Date.today(); save with Database.update(scope, false).finish() — log the job Id with System.debug.Database.getQueryLocator() — not a List — so Salesforce paginates up to 50M rows.execute() with scope.isEmpty().Database.update(scope, false) — partial saves: one bad record does not roll back the chunk.Write LeadScoreResetBatch that implements both
Database.Batchable<SObject> and Database.Stateful.
It resets Lead_Score__c to 0 on all unconverted Leads
and tracks how many records succeeded and failed across all chunks.
Database.Batchable<SObject> and
Database.Stateful.private Integer processedCount = 0 and
private Integer failedCount = 0 as instance variables.start() — query unconverted Leads with Lead_Score__c > 0.execute() — reset Lead_Score__c = 0, save with
Database.update(scope, false), inspect Database.SaveResult[]
to increment counters.finish() — log the totals.Without Database.Stateful, instance variables are reset to their defaults
between each execute() call. With it, they persist across all chunks —
essential for aggregating results.
Write ContactEmailDomainBatch that enforces an allowed email domain
(salesforce.com) across all Contacts. Contacts with non-compliant emails
should have their Email field cleared. Use partial DML and capture errors.
Database.Batchable<SObject> and Database.Stateful.ALLOWED_DOMAIN, errorLog, successCount.start() — query Contacts WHERE Email != null.execute() — for each Contact, if email doesn't end with
@salesforce.com, set Email = null and add to update list.
Use Database.update(toUpdate, false) and inspect SaveResult[].finish() — log success count and all error messages.toUpdate list — don't update all scope records, only those that changed.toUpdate.isEmpty() before DML — avoids unnecessary governor usage.results[i] alongside toUpdate[i] to correlate
errors back to the correct record Id.Write AccountAddressSyncBatch that copies each Account's billing address
to all of its child Contacts' mailing address fields in bulk.
Database.Batchable<SObject>.start() — query Accounts that actually have a billing city populated.execute():
Map<Id, Account> from scope for O(1) lookup.BillingCity → MailingCity, BillingState → MailingState,
BillingPostalCode → MailingPostalCode, BillingCountry → MailingCountry.Database.update(toUpdate, false).A SOQL inside execute() is allowed (one per chunk) — but it must be
outside any loop. Build the Id set first, then query once.
Implement two chained batch classes:
Total_Closed_Won_Revenue__c,
then chains AccountTierAssignmentBatch in finish().Account_Tier__c
(Platinum/Gold/Silver/Bronze) based on the revenue field.finish() with if (!Test.isRunningTest()) —
you cannot enqueue from inside a batch during test execution.RevenueRollupBatch must be Database.Stateful to count
processed Accounts across chunks.Write WeeklyOpportunityCleanupScheduler that implements
Schedulable and kicks off StaleOpportunityBatch
on a scheduled basis.
Schedulable.@TestVisible private Integer batchSize = 200 field.execute(SchedulableContext sc) — creates a
new StaleOpportunityBatch() and calls
Database.executeBatch(batch, batchSize).String cron = '0 0 2 ? * MON *'; // Every Monday at 2 AM String jobName = 'Weekly Opp Cleanup'; System.schedule(jobName, cron, new WeeklyOpportunityCleanupScheduler());
@TestVisible field so tests can override it.execute() minimal — one line to create and enqueue the batch.Seconds Minutes Hours Day Month DayOfWeek Year.Write MonthlyLeadSummaryScheduler that implements Schedulable
and emails a monthly report of new unconverted Leads to an admin.
Schedulable.@TestVisible private String recipientEmail = 'admin@salesforce.com'.execute(SchedulableContext sc):
COUNT() of Leads that are unconverted
(IsConverted = false) and were created during the current month,
using the THIS_MONTH date literal.Messaging.SingleEmailMessage with subject and body.Messaging.sendEmail() guarded by
!Test.isRunningTest().Messaging.sendEmail() with !Test.isRunningTest()
— sending email in tests uses the daily email allowance.THIS_MONTH date literal — cleaner than calculating start/end dates.@TestVisible field for testability.Write HourlyDataMonitorScheduler that runs every hour, counts new open Cases,
alerts on spikes, and re-schedules itself for the next hour automatically.
Schedulable.private static final String JOB_NAME = 'HourlyDataMonitor'.@TestVisible private static String buildNextHourCron() — builds a CRON
expression for exactly one hour from now using DateTime.now().addHours(1)
and String.format().execute():
CreatedDate >= :oneHourAgo.System.abortJob(sc.getTriggerId()).System.schedule(JOB_NAME, buildNextHourCron(), new HourlyDataMonitorScheduler()).!Test.isRunningTest().Salesforce Scheduled Jobs run once. To simulate a recurring job at intervals shorter
than 1 day (which standard Apex Scheduling via UI cannot express), the job re-schedules
itself in execute(). Always abort the current job first to avoid duplicate
schedules building up.
Write a batch class InactiveUserDeactivateBatch that deactivates
all active Salesforce Users who have not logged in for 90 or more days.
Also write a complete @isTest class that covers all three batch methods.
Database.Batchable<SObject>.start() — return a Database.QueryLocator for Users that are
still active and have not logged in since a cutoff date
(LastLoginDate <= :cutoff, cutoff = today minus 90 days).execute() — set IsActive = false on each User;
save with Database.update(scope, false).finish() — log the Job Id with System.debug.@isTest annotation and Test.startTest() / Test.stopTest().Database.executeBatch() runs without error.start() returns a non-null QueryLocator.execute() handles an empty scope gracefully.Write CaseEscalationBatch that escalates open Cases older than
7 days by setting their Priority to 'High'.
Cases already at 'High' priority must be skipped. Use
Database.Stateful to track escalated and skipped counts. Also write a
complete @isTest class with data setup and assertions.
Database.Batchable<SObject> and Database.Stateful.private Integer escalatedCount = 0 and private Integer skippedCount = 0.start() — query Cases WHERE Status != 'Closed' AND CreatedDate <= :cutoff (7 days ago).execute() — skip already-High cases (increment skippedCount); set Priority = 'High' on others and increment escalatedCount on success.finish() — log both counters.@TestSetup to insert test Cases (some Low priority, one already High).Test.startTest() / Test.stopTest() around Database.executeBatch().Write ContractRenewalReminderBatch that creates a Task
reminder for every Activated Contract expiring in the
next 30 days. Track created and error counts across all chunks.
Database.Batchable<SObject> and Database.Stateful.private Integer createdCount = 0 and private Integer errorCount = 0.start() — query Contracts that are Activated and whose EndDate falls
within the next 30 days, using a relative date literal rather than computed dates.execute() — for each Contract, create a Task with:
Subject = 'Contract Renewal: ' + con.ContractNumberWhatId = con.IdActivityDate = Date.today().addDays(1)Status = 'Not Started', Priority = 'High'Database.insert(toInsert, false) and inspect SaveResult[].finish() — log both counters.SOQL provides relative date literals meaning "from today to N days from now" — no Apex date math needed in the query.
Write OpportunityOwnerReassignBatch that reassigns all open
Opportunities owned by inactive Users to a configurable default owner.
Track the total number of reassignments across all chunks.
Database.Batchable<SObject> and Database.Stateful.private Id defaultOwnerId and
private Integer reassignedCount = 0.Id defaultOwnerId and stores it via this.defaultOwnerId.start() — query open Opportunities (IsClosed = false) whose
owner is inactive, using the cross-object filter Owner.IsActive = false.execute() — set OwnerId = defaultOwnerId on each Opportunity.
Use Database.update(toUpdate, false) and inspect SaveResult[].finish() — log reassignedCount.Owner.IsActive = false in the WHERE clause traverses the User relationship
directly in SOQL — no Apex-side filtering needed.
Write AccountHealthScoreBatch that calculates and saves an
Account_Health_Score__c (0–100) for every Account. The score is derived
from open Opportunity count and open Case count using parent-child subqueries in start().
Database.Batchable<SObject> and Database.Stateful.private Integer processedCount = 0 and
private Integer errorCount = 0.start() — use a parent-with-child subquery to retrieve each Account's
open Opportunities AND open Cases in one query.execute():
acc.Cases.size() and acc.Opportunities.size().healthScore = 100 - (openCaseCount * 10) + (openOppCount * 5)Math.max(0, Math.min(100, healthScore)).Account_Health_Score__c; increment processedCount.finish() — log both counters.Placing a subquery inside Database.getQueryLocator fetches related child
records for each parent in the same SOQL, completely avoiding SOQL-in-loop violations.
Write a batch class NoActivityAccountBatch that sets
Description to 'Needs Review' on every
Account that has no Contacts and
no Opportunities.
Database.Batchable<SObject>start() — query ALL accounts with subqueries for Contacts and Opportunities (LIMIT 1 each)execute() — check both subquery lists, update only qualifying accountsisEmpty() checkDatabase.update(list, false) for partial-DML safetyfinish() — log completion with System.debug'Needs Review' in a named constantWrite a batch class OverdueOppFollowUpBatch that creates a
follow-up Task for every open Opportunity whose
CloseDate is in the past.
Database.Batchable<SObject>start() — query open opportunities with IsClosed = false AND CloseDate < TODAYexecute() — create one Task per opportunity:
Subject: 'Follow Up: ' + opp.NameWhatId: opportunity IdOwnerId: opportunity ownerActivityDate: today + 3 days (use a constant)Status = 'Not Started', Priority = 'High'Database.insert(tasks, false) with isEmpty() guardWrite a stateful batch class
ContactTitleNormalizeBatch that normalises the
Title field of every Contact to
sentence-case (first letter upper, rest lower) and counts the total
number of records updated across all batches.
Database.Batchable<SObject> and
Database.Statefulglobal Integer totalUpdated = 0 instance variablestart() — query Contacts where Title != nullexecute() — trim, lower-case, then capitalise the first character; skip records already correcttotalUpdated only when records are actually changedfinish() — log totalUpdated with System.debugWrite a batch class StaleWaitingCaseBatch that automatically
closes Case records whose Status is
'Waiting on Customer' and have not been modified in
14 or more days.
Database.Batchable<SObject>STALE_DAYS = 14,
WAITING = 'Waiting on Customer',
CLOSED_STATUS = 'Closed'start() — compute cutoff as Date.today().addDays(-STALE_DAYS);
filter Status = :WAITING AND LastModifiedDate <= :cutoffexecute() — set Status = 'Closed' and
Description to a closure reason; use
Database.update(list, false)finish() — log completionWrite a batch class UncontactedLeadReassignBatch that
reassigns stale, uncontacted Lead records to a Salesforce Queue
named 'Uncontacted_Leads'.
Database.Batchable<SObject>Group object
(type = 'Queue') for the queue Id — store as an instance variableSTALE_DAYS = 30,
QUEUE_NAME = 'Uncontacted_Leads',
STATUS_OPEN = 'Open - Not Contacted'start() — query unconverted leads with the open status created
more than 30 days agoexecute() — if queue found, set OwnerId = queueId;
use Database.update(list, false)finish() — log completionWrite a Batch Apex class UpdateInactiveContactsBatch that marks all
inactive Contacts by updating their Description field.
A Contact is inactive when its LastActivityDate is older than 365 days,
or when LastActivityDate is null and CreatedDate is older than 365 days.
Database.Batchable<SObject> with all three methods.Date.today().addDays(-365)) and
an overloaded constructor accepting a custom Date cutoffDate for testability.start(): return a Database.QueryLocator filtering by the inactivity criteria.execute(): set Description = '[INACTIVE] Reviewed: ' + String.valueOf(Date.today());
do bulk DML outside the loop.finish(): use System.debug to print the job ID — result visible in the Output tab.update scope once outside it.try/catch(DmlException).Write DeleteDuplicateLeadsBatch that removes duplicate unconverted Leads,
keeping the most recently created Lead per email address.
Database.Batchable<SObject> and Database.Stateful.private Integer totalDeleted = 0 as a stateful counter.start(): query non-converted Leads with a non-null Email, sorted so that
within each email group the most recently created Lead comes first.execute(): group Leads by email (case-insensitive), keep index 0 (newest),
delete the rest using Database.delete(list, false).Database.DeleteResult; increment totalDeleted on success.finish(): System.debug the total deleted count — visible in the Output tab.allOrNone = false so one bad record does not abort the entire delete.DeleteResult errors rather than swallowing them silently.if (!toDelete.isEmpty()).Write ArchiveClosedOpportunitiesBatch that stamps the
Description field of all Closed Won or Closed Lost Opportunities
whose CloseDate is older than 2 years.
Database.Batchable<SObject>.Date.today().addYears(-2))
and one accepting a custom Date cutoffDate.start(): query Opportunities restricted to the Closed Won / Closed Lost
stages whose CloseDate is older than the cutoff.execute(): set Description = '[ARCHIVED] ' + today;
bulk update outside the loop.finish(): System.debug the job ID — visible in the Output tab.Write UpdateAccountRatingsBatch that reads every Account and sets
the Rating picklist field based on AnnualRevenue:
Database.Batchable<SObject>.private static final Decimal constants.execute(): null-safe revenue check; assign Rating; bulk update outside loop.finish(): System.debug the job ID — visible in the Output tab.Every problem above runs in a real in-browser Apex editor with instant pass/fail feedback and best-practice linting. No Salesforce org needed.
Create Free Account →Write YearlySalesPerformanceBatch that aggregates total Closed Won
Opportunity revenue for the current calendar year per Account and writes the result
to each Account's Description field.
Database.Batchable<SObject> and Database.Stateful.private Map<Id, Decimal> accountAmountMap to accumulate totals.execute(): accumulate Amount — no DML here.finish(): bulk update Accounts; System.debug the count — visible in the Output tab.Write InactiveAccountsBatch that marks Accounts as inactive when
they have not been modified in 5 years AND have no open Opportunities.
Database.Batchable<SObject>.start(): select Accounts whose LastModifiedDate is older than the cutoff and that have no open Opportunities — use an anti-join subquery, not an Apex loop.execute(): stamp Description = '[INACTIVE ACCOUNT] Reviewed: ' + today; bulk update outside loop.finish(): System.debug the job ID — visible in the Output tab.Write ArchiveClosedCasesBatch that stamps the Description
of all closed Cases whose ClosedDate is older than 2 years.
Database.Batchable<SObject>.start(): select closed Cases whose ClosedDate is older than the cutoff.execute(): stamp [ARCHIVED]; bulk update outside loop.finish(): System.debug the job ID — visible in the Output tab.Write UpdateOpportunityProbabilityBatch that sets Probability
for every Opportunity based on StageName using a static map.
private static final Map<String, Integer>.start(): query only Opportunities whose StageName is a key in
STAGE_PROBABILITY_MAP, using the map's key set as the filter.execute(): guard with isEmpty() before DML.finish(): System.debug the job ID — visible in the Output tab.Write MassLeadAssignmentBatch that assigns all open non-converted
Leads to a list of owners in round-robin order across all batch chunks.
Database.Batchable<SObject> and Database.Stateful.List<Id> ownerIds; throws IllegalArgumentException if null/empty.assignmentIndex = 0 persists across chunks.execute(): Math.mod(assignmentIndex, ownerCount); bulk update outside loop.finish(): System.debug total assigned — visible in the Output tab.Write CleanDuplicateContactsBatch that removes duplicate Contacts
within the same Account by the composite key AccountId + Email (case-insensitive).
Keep the oldest Contact per group; delete the rest.
Database.Batchable<SObject>.start(): query only Contacts that have both an AccountId and an
Email, sorted so records sharing the same Account and Email are adjacent and the
oldest one in each group comes first.execute(): group by AccountId + '|' + email.toLowerCase(); keep index 0; delete the rest with Database.delete(list, false).finish(): System.debug the job ID — visible in the Output tab.Write two Batch Apex classes that run as a two-step pipeline:
RecalculateAccountRevenueBatch — recomputes each Account's
AnnualRevenue as the sum of its Closed Won Opportunity
Amounts.AccountTierBatch — sets each Account's Rating to
Hot/Warm/Cold based on the (now up-to-date)
AnnualRevenue.The first batch's finish() method should chain the second batch — but
only if the first batch actually changed something.
RecalculateAccountRevenueBatch implements
Database.Batchable<SObject>, Database.Stateful so it can track how many
Accounts were updated across all batch chunks.GROUP BY query per
execute() call — never query per record.finish(), call Database.executeBatch(new AccountTierBatch(), 200)
only when accountsUpdated > 0.Database.Stateful is required to keep a running counter across chunks —
without it, every execute() call gets a fresh instance.try/catch(DmlException).Write a Batch Apex class SpecificAccountsBatch that processes only a
caller-supplied list of Account Ids (passed into the constructor), stamping
each one's Description to mark it as reviewed.
Instead of returning a Database.QueryLocator from start(),
implement it using a custom Iterable<SObject> —
AccountIterable and AccountIterator — to practice the pattern used
when a batch's scope isn't a plain SOQL query result.
SpecificAccountsBatch(List<Id> accountIds) constructor stores the Ids.start() returns Iterable<SObject>, backed by an
AccountIterable wrapping the queried Accounts.AccountIterable implements Iterable<SObject> and returns
an AccountIterator.AccountIterator implements Iterator<SObject> with
hasNext() and next().execute() sets Description = 'Reviewed via curated batch run'.Iterable is the right tool when the batch's working set comes
from more than "run this one SOQL query" — e.g. a pre-curated list, records merged from
multiple queries, or synthetic data. For a plain SOQL scan, Database.QueryLocator
is simpler and should still be preferred.start(), not once per
iterator step — the iterator just walks an already-fetched, in-memory list here.try/catch(DmlException).Write a Batch Apex class UpdateOpportunityDiscountBatch that sets
Discount__c on every open Opportunity — 10 if
Amount > 100000, otherwise 5.
Any record that fails to update must be captured (not silently dropped) and written to a
custom object Batch_Error_Log__c once the job finishes.
Database.Batchable<SObject>, Database.Stateful — a running
List<String> failureMessages must survive across every chunk.Database.update(scope, false) (partial success) instead of a
plain update scope; — inspect each Database.SaveResult for
failures and append a descriptive message per error.finish(): if any failures were recorded, bulk-insert one
Batch_Error_Log__c per failure message (fields: Message__c,
Batch_Job__c).allOrNone = false means one bad record in a chunk doesn't roll back every
other record in that same chunk — essential for large batch jobs processing imperfect data.Database.Stateful is required here too — without it, failureMessages
would reset to empty on every chunk and finish() would only ever see the last
chunk's failures.finish(), not per chunk — keeps the
log tidy and avoids DML inside execute() beyond the primary update.Write a Batch Apex class SyncAccountsCalloutBatch that pushes every Account
with Sync_Status__c = 'Pending' to an external CRM via a single bulk HTTP
callout per chunk, then stamps each Account as Synced or Failed
based on the response.
Database.Batchable<SObject>, Database.AllowsCallouts —
the second interface is required for any HTTP callout inside a batch job.execute(): build one JSON payload covering the whole
scope and send one callout — never one callout per record.200 response, mark every Account in the chunk Synced;
otherwise (bad status code, or a caught CalloutException) mark them Failed.callout:External_CRM/...),
never a hardcoded URL.execute()
transaction — one callout per chunk (not per record) is what keeps that safe at any batch size.Database.executeBatch(new SyncAccountsCalloutBatch(), 10) rather
than the default 200, to stay within callout timeout limits per transaction.callout:External_CRM) keep the endpoint and auth out of
source code — never hardcode the raw URL or credentials.CalloutException — a network failure must not crash the whole batch chunk.Write a Batch Apex class FlagStaleLeadsBatch that sets
Status = 'Stale' on every non-converted Lead whose LastActivityDate
is more than 90 days old (or was never set at all).
Database.Batchable<SObject> with all three methods.start(): compute the 90-day cutoff Date once, bind it into the query.execute(): set Status = 'Stale' in the loop, then a single
bulk update outside it.Date once per start() call, not inline
inside the SOQL string.try/catch(DmlException).Write a Batch Apex class CloseStaleOpportunitiesBatch that closes (as
Closed Lost) every open Opportunity whose CloseDate is more than
180 days in the past, and stamps each affected Opportunity's parent Account
with a note that it had a stale deal auto-closed.
start(): query open Opportunities with CloseDate older than
a 180-day cutoff.execute(): build the Opportunity updates and collect the related
AccountIds in the same loop; run the Opportunity update.update on it
throws a runtime error, so this always needs two separate bulk DML calls.Set<Id> in the same pass
you build the Opportunity updates — don't re-loop or re-query for something you already
have on hand.try/catch(DmlException)
— a failure updating Accounts shouldn't be conflated with (or silently hide) a failure
updating Opportunities.Premium-tier support is expensive to staff. Any Account still marked
Tier__c = 'Premium' that hasn't had any activity in 180 days
should be automatically downgraded to 'Standard'.
Tier__c = 'Standard'.A departing sales rep's entire book of Accounts needs to move to a replacement rep, with a
permanent audit record of the change for compliance. Write a Batch Apex class
ReassignAccountOwnerBatch that moves every Account owned by one User to another,
logging each change to a custom object Account_Ownership_Log__c.
fromOwnerId and toOwnerId.start() queries every Account currently owned by fromOwnerId.execute() reassigns OwnerId and inserts one
Account_Ownership_Log__c per Account, capturing the old owner, new owner, and
timestamp.OwnerId in memory, the original value is gone unless you
record it first.A one-time data backfill needs to compute Territory_Code__c for every Account
missing it, derived from Region__c. Given the volume, some records may fail
(validation rules, field-length limits) — track failures, log them, and automatically retry
by re-launching the batch (since a fresh start() query will only pick up
Accounts still missing Territory_Code__c, which naturally excludes everything
that already succeeded).
Database.Batchable<SObject>, Database.Stateful to track
running success/failure counts across all chunks.Territory_Code__c as the first 3 letters of Region__c,
uppercased (or 'UNK' if Region__c is blank).Database.update(scope, false) so one bad record doesn't block the rest
of the chunk; records every failure's message.finish(), if there were any failures: log them all to
Batch_Error_Log__c, then re-launch the batch to retry — since
start()'s query only ever selects Accounts still missing
Territory_Code__c, the retry naturally covers just the leftover failures.start()'s query to be self-limiting (only ever selects
remaining incomplete work) is what makes "re-launch on failure" safe — it can never
re-process already-successful records or loop forever once everything succeeds.Database.Stateful is required to keep recordsProcessed/
recordsFailed accurate across every chunk of a potentially large job.Every night, recompute every Account's revenue-based tier (Standard / Gold / Platinum). Any Account that newly reaches Platinum should generate a follow-up Task for its owner — but the notification step should only look at the Accounts that actually changed, not re-scan the whole org.
AccountTieringPipelineBatch (Database.Batchable<SObject>,
Database.Stateful): recomputes Tier__c for every Account
(Platinum >= 10,000,000 revenue, Gold >= 1,000,000, else Standard), tracking the Ids
of Accounts that newly became Platinum this run.finish(), if any Accounts newly reached Platinum, enqueue
PlatinumUpgradeNotifyQueueable with just those Ids.PlatinumUpgradeNotifyQueueable re-queries those Accounts and creates one
Task per Account for its owner.finish() (rather than
another Batch) is the right choice here — the follow-up work (a handful of Task inserts) is
small and doesn't need its own governor-limit-scoped chunks.newTier != a.Tier__c),
avoiding a no-op DML write on every single Account every night.Data governance wants a reusable batch that can check any set of field-level rules against Accounts — e.g. "Website must look like a URL", "Phone must be 10+ digits" — without writing a new batch class every time a rule is added. Build it around a caller-supplied list of rules, evaluated dynamically via generic field access.
DataQualityRule: a simple data class holding fieldName and
requiredPattern (a regex the field's value must match).DataQualityRuleRunnerBatch constructed with
List<DataQualityRule> rules.start() dynamically builds its SELECT clause from every rule's
fieldName (plus Id), so the batch works for whatever fields the
caller's rules reference — no hardcoded field list.execute(): for each Account and each rule, read the field's value via
SObject.get(fieldName), and if it doesn't match the rule's
requiredPattern, insert a Data_Quality_Violation__c recording the
record, field name, and offending value.SObject.get(fieldName) + Pattern.matches() is the standard
pattern for evaluating a field generically without knowing its name at compile time.Pattern.matches().Sales operations wants Account.Customer_Status__c refreshed once a month so
reps can trust it reflects real recent engagement, not whatever was set at Account creation.
MonthlyCustomerStatusBatch implementing Database.Batchable<SObject>.LastActivityDate is within the last
INACTIVITY_DAYS (90) days; otherwise it is Dormant.Customer_Status__c
actually differs from the computed value.execute() must issue a single bulk update call, never DML per record.INACTIVITY_DAYS) instead of a bare 90 keeps the
rule self-documenting and easy to tune later.try/catch(DmlException) so one bad chunk doesn't
surface an unhandled exception in the job's history.Marketing wants stale, never-responded CampaignMember records purged
periodically so Campaign response-rate reports stay meaningful and lists don't grow unbounded.
DeleteExpiredCampaignMembersBatch implementing Database.Batchable<SObject>.EXPIRATION_DAYS (180) days ago
that never responded (HasResponded = false).execute() must delete the whole scope in a single bulk DML call.start()'s query (rather than in Apex after querying everything)
keeps each chunk small and avoids wasting governor limits on records that won't be touched.delete scope; call handles the whole chunk in one DML statement —
never delete inside a loop.try/catch(DmlException) so a locked record in one chunk
doesn't silently kill the whole job.Finance wants product list prices adjusted once a year by a per-product inflation rate
stored on a custom field, instead of manually editing every Product2 record.
AnnualPriceAdjustmentBatch implementing
Database.Batchable<SObject>, Database.Stateful to track how many products
were adjusted across all chunks.Annual_Adjustment_Percent__c.Standard_Price__c * (1 + Annual_Adjustment_Percent__c / 100),
rounded to 2 decimal places.Standard_Price__c instead of erroring.Database.Stateful is required for productsAdjusted to survive
across every execute() chunk — without it the counter resets to 0 each time.Annual_Adjustment_Percent__c != null in the query keeps the batch
from wasting chunks on Products that would never change.setScale(2) avoids floating-point noise accumulating in currency fields.Sales leadership doesn't trust the pipeline forecast because reps rarely update
ForecastCategoryName manually. A nightly batch should realign it with objective
signals: stage, close date, and how long an Opportunity has sat without progressing.
RealignForecastCategoryBatch implementing Database.Batchable<SObject>.IsClosed = false).CloseDate has passed, or it
has been stalled (no stage change) for at least STALLED_DAYS_THRESHOLD (45) days.Negotiation/Review
or Proposal/Price Quote map to Best Case; anything else keeps its current category.toUpdate avoids
needless DML rows and preserves field history for genuine changes only.update outside the loop.When an agent goes inactive (leaves the team, is deactivated), their open Cases are orphaned. Support operations needs a batch that reassigns those Cases to active agents who still have spare capacity, instead of a blind round-robin.
ReassignCasesToActiveAgentsBatch implementing
Database.Batchable<SObject>, Database.Stateful.Owner.IsActive = false).MAX_OPEN_CASES_PER_AGENT (15) as reassignment targets.User.IsActive plus a real open-Case count (not just
"any active user") prevents dumping work onto an agent who is already overloaded.loadAgentCapacity() and reused/mutated in
memory across the whole job — no per-record SOQL.Database.Stateful keeps casesReassigned (and the capacity map's
cumulative state via the cursor) consistent across chunks.update call per chunk.Billing needs one Invoice__c generated per year for every Activated
Contract. This runs as a scheduled yearly batch job across the whole Contract population.
GenerateYearlyInvoicesBatch implementing
Database.Batchable<SObject>, Database.Stateful, tracking how many
invoices were created.invoiceYear to the current year) and
an overloaded constructor accepting an explicit year, for testability and back-filling.Status = 'Activated'.Invoice__c
for the same Contract and year, and skip Contracts that already have one.Invoice__c with Contract__c, Account__c,
Invoice_Year__c, Invoice_Date__c, and Status__c = 'Draft'.insert — never per-record
SOQL or DML.Database.Stateful is required to keep invoicesCreated accurate
across chunks.Finance already loads a nightly extract of ERP account balances into a custom staging object,
ERP_Sync_Log__c. This batch performs a purely internal reconciliation
— comparing the ERP-side balance already sitting in that object against the corresponding
Salesforce Account.AnnualRevenue — and flags mismatches for manual review.
There is no external HTTP callout here; the ERP data has already landed.
ErpReconciliationBatch implementing
Database.Batchable<SObject>, Database.Stateful, counting how many
discrepancies were flagged.ERP_Sync_Log__c records (Reconciled__c = false).ERP_Balance__c against the related Account.AnnualRevenue;
flag Out_Of_Sync__c = true when the absolute difference exceeds
AMOUNT_TOLERANCE (0.01).Reconciled__c = true with today's
Reconciled_Date__c, regardless of whether it was in sync.AMOUNT_TOLERANCE constant avoids false-positive discrepancies caused
by floating-point/rounding noise.Account__r.AnnualRevenue) in the same
query avoids a second SOQL per chunk.Database.Stateful keeps the discrepanciesFlagged total accurate
across the whole job for the final summary in finish().update per chunk, wrapped in try/catch(DmlException).Every night, an external file-load process drops rows into a staging custom object,
Product_Import_Staging__c. Batch Apex cannot itself receive a file, so this batch
picks up unprocessed staging rows and upserts them into Product2.
ProductCatalogImportBatch implementing
Database.Batchable<SObject>, Database.Stateful, tracking created/updated/failed counts.Processed__c = false).ProductCode__c or Name__c are invalid: mark them
processed with an Import_Error__c reason and skip upserting them.ProductCode; use upsert ... ProductCode
(upsert on an external-id-like field) to create-or-update in a single DML statement.Processed__c = true once handled, valid or not.WHERE ProductCode IN :codes)
avoids a SOQL call per staging row.upsert toUpsert ProductCode; collapses "does this already exist" branching
into a single DML statement instead of separate insert/update lists.Database.Stateful keeps the create/update/fail counters accurate for the
final finish() summary.Loyalty points for enrolled Contacts have drifted from actual purchase history. This batch recalculates every loyalty member's points from their lifetime Activated Order spend, and upgrades their tier once they cross a points threshold.
RecalculateLoyaltyPointsBatch implementing
Database.Batchable<SObject>, Database.Stateful, counting tier upgrades.Loyalty_Member__c = true.GROUP BY query per chunk — never per-Contact SOQL.spend * POINTS_PER_DOLLAR (2); tier is Platinum once
points reach TIER_UPGRADE_THRESHOLD (5000), otherwise Standard.ContactId keeps spend calculation
governor-safe regardless of how many Orders exist per Contact.toUpdate avoids no-op DML
on Contacts whose recalculated values didn't change.Database.Stateful is required for contactsUpgraded to
accumulate correctly across every chunk in the job.A high-volume distributor has millions of Order records. Fulfillment status
must reflect real shipping progress on the underlying OrderItem lines, but nothing
keeps it in sync in real time. This nightly batch recalculates it at scale.
OrderFulfillmentStatusBatch implementing
Database.Batchable<SObject>, Database.Stateful, counting Orders updated.start() must use a narrow, indexed filter (Status IN ('Draft','Activated'))
— never a full unfiltered scan of the Order table at this volume.GROUP BY queries — never per-Order SOQL.Activated; some but not all shipped → Draft
(partially fulfilled); otherwise leave the status unchanged.start() filter is what makes the
QueryLocator viable at all — an unfiltered query wastes the whole job on records
that can never change.Database.Stateful keeps ordersUpdated accurate for the final
summary across every chunk.Executives want one consolidated Enterprise_Report__c row per sales owner
summarizing this year's Closed Won revenue and deal count, aggregated across the entire
Opportunity table.
EnterpriseReportBatch implementing
Database.Batchable<SObject>, Database.Stateful, accumulating per-owner
revenue and deal-count totals across every chunk.start() filters to Closed Won Opportunities closed in the
current reportYear.execute() only accumulates in-memory totals into the stateful maps — it must
not perform any DML per chunk.finish() converts the accumulated maps into one Enterprise_Report__c
row per owner and inserts them all in a single bulk DML call.finish() is correct here because the summary rows
depend on totals accumulated across the entire job, not any single chunk.Database.Stateful is what allows revenueByRegionOwner and
countByRegionOwner to keep accumulating correctly across every execute()
call instead of resetting each time.finish() is still a single bulk DML statement, not one
insert per owner.A global SaaS company tracks customer subscriptions in a multi-currency org via
Subscription__c. Every day, due subscriptions must either auto-renew (advance the
renewal date one month) or expire, across every currency and region, with per-record failures
isolated so a handful of bad rows never abort the whole run.
GlobalSubscriptionRenewalBatch implementing
Database.Batchable<SObject>, Database.Stateful, tracking renewed/expired/failed counts.Active subscriptions whose Renewal_Date__c is due
(<= TODAY) — this must work correctly regardless of the org's multi-currency setup.Auto_Renew__c = true advances Renewal_Date__c
by one month and stays Active; otherwise it becomes Expired.Amount__c before renewing —
invalid records must not block the rest of the chunk.Database.update(toUpdate, false) (partial success) so a DML failure on
one record doesn't roll back the whole chunk, and record per-record failures.Database.update(list, false) with a per-result check is exactly the pattern
for "some records may legitimately fail — keep processing the rest," which matters at global
scale where a single bad currency conversion or missing field shouldn't halt the whole batch.Database.Stateful keeps the renewed/expired/failed counters — and the
failure log — accurate across every chunk in the job.Decimal values on
the record; no special handling is needed beyond respecting CurrencyIsoCode when
querying/reporting.Write and run real Apex code right in your browser — instant pass/fail feedback, best-practice linting, and governor limit monitoring. No Salesforce org needed.
Create Free Account → Explore All ProblemsApexArena is a free, browser-based Salesforce Apex coding practice platform covering every major topic tested on the Salesforce Platform Developer I (PD1) and Platform Developer II (PD2) certification exams. All problems run directly in your browser with instant pass/fail feedback, best-practice linting (SOQL in loops, DML in loops, empty catch blocks), and governor limit monitoring — no Salesforce Developer Edition org required.
Related tutorials: Apex Triggers · SOQL · Batch Apex · Interview Q&A · Governor Limits