This guide walks through 35 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 Id, Title, Account.Industry FROM Contact WHERE AccountId != null (child-to-parent).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
(SELECT Id FROM Contacts LIMIT 1).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
WHERE StageName = 'Prospecting', IsClosed = false,
CreatedDate <= :cutoff (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 WHERE BillingCity != null.execute():
Map<Id, Account> from scope for O(1) lookup.WHERE AccountId IN :accountIds.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 WHERE IsConverted = false
AND CreatedDate = THIS_MONTH.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 WHERE
IsActive = true AND 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 WHERE EndDate = NEXT_N_DAYS:30
AND Status = 'Activated'.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.NEXT_N_DAYS:30 is a SOQL date literal meaning "from today to 30 days from now"
— no Apex date math needed in the query.
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 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 Opportunities WHERE IsClosed = false
AND Owner.IsActive = false (cross-object filter).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,
ordered by Email, CreatedDate DESC (newest first per group).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 where
StageName IN ('Closed Won','Closed Lost') AND CloseDate < :cutoffDate.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.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(): anti-join subquery — WHERE LastModifiedDate < :cutoffDateTime AND Id NOT IN (SELECT AccountId FROM Opportunity WHERE IsClosed = false ...).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(): FROM Case WHERE IsClosed = true AND ClosedDate < :cutoffDateTime.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(): WHERE StageName IN :stages using keySet().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(): WHERE AccountId != null AND Email != null ORDER BY AccountId, Email, CreatedDate ASC.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 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