Batchable Apex

Batch Apex Practice Problems

35 free Salesforce Apex challenges · Full problem statements, best-practice notes, and step-by-step hints · Solve live on ApexArena

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.

On this page
  1. 1. Batch Apex Deduplication
  2. 2. Batch Apex Opportunity Archiver
  3. 3. Batch Apex — Track Failed Record Updates
  4. 4. Batch Apex: Categorise Accounts by Annual Revenue Tier
  5. 5. Batch Apex: Deactivate Stale Contacts with Partial DML
  6. 6. Batch Apex: Sync Contact Title from Account Industry
  7. 7. Batch Apex: Recalculate Account Engagement Score with Error Logging
  8. 8. Batch Apex (Easy): Mark Stale Opportunities as Closed Lost
  9. 9. Batch Apex (Easy-Medium): Stateful Batch — Count Processed Leads
  10. 10. Batch Apex (Medium): Contact Email Domain Enforcement with Partial DML
  11. 11. Batch Apex (Medium): Sync Account Billing Address to Child Contacts
  12. 12. Batch Apex (Hard): Chained Batch — Revenue Rollup then Account Tier
  13. 13. Schedulable (Easy): Weekly Batch Job Scheduler
  14. 14. Schedulable (Medium): Monthly Lead Summary Email
  15. 15. Schedulable (Hard): Self-Rescheduling Hourly Data Monitor
  16. 16. Batch Apex (Easy): Deactivate Users Inactive for 90+ Days
  17. 17. Batch Apex (Easy-Medium): Stateful Batch — Escalate Stale Cases
  18. 18. Batch Apex (Medium): Create Renewal Reminder Tasks for Expiring Contracts
  19. 19. Batch Apex (Medium-Hard): Reassign Opportunities from Inactive Users
  20. 20. Batch Apex (Hard): Calculate Account Health Score via Subquery Rollup
  21. 21. Batch Apex: Tag Accounts With No Contacts or Opportunities
  22. 22. Batch Apex: Create Follow-Up Tasks for Overdue Open Opportunities
  23. 23. Batch Apex: Stateful — Count How Many Contacts Were Updated
  24. 24. Batch Apex: Auto-Close Cases Waiting on Customer for 14+ Days
  25. 25. Batch Apex: Reassign Uncontacted Leads Older Than 30 Days to a Queue
  26. 26. Batch Apex: Update Inactive Contacts
  27. 27. Batch Apex: Delete Duplicate Leads by Email
  28. 28. Batch Apex: Archive Closed Opportunities Older Than 2 Years
  29. 29. Batch Apex: Update Account Ratings by Annual Revenue
  30. 30. Batch Apex: Yearly Sales Performance Aggregation (Stateful)
  31. 31. Batch Apex: Mark Inactive Accounts with No Open Opportunities
  32. 32. Batch Apex: Archive Closed Cases Older Than 2 Years
  33. 33. Batch Apex: Update Opportunity Probability by Stage Name
  34. 34. Batch Apex: Round-Robin Lead Assignment (Stateful)
  35. 35. Batch Apex: Clean Duplicate Contacts by Account and Email
Medium BatchGovernor

1. Batch Apex Deduplication

Problem #4 · Salesforce Apex Coding Challenge

Problem Statement

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.

Requirements

  • Batch size: 200 records
  • Use Database.merge() for the actual merging
  • The finish() method must send a summary email
Approach
  • 1The first item in each email group (sorted DESC by CreatedDate) is the master record to keep.
  • 2Call Database.merge(masterLead, duplicateIds) — it handles field merging automatically.
  • 3In finish(), use Messaging.sendEmail() with a SingleEmailMessage to notify an admin.
Hard Batch ApexApex Classes

2. Batch Apex Opportunity Archiver

Problem #75 · Salesforce Apex Coding Challenge

Problem Statement

Write a Batch Apex class that archives closed Opportunities older than a given number of days.

Requirements:

  • Implement Database.Batchable<SObject> interface
  • Constructor accepts Integer 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 < :cutoffDate
  • execute(): set Archived__c = true on each record in the batch and update
  • finish(): send an email to the running user with the batch completion summary
  • Also implement Database.Stateful to track total records processed across batches

Constraints

  • Use Database.update(scope, false) for partial success
  • Track failed record count using a stateful instance variable
Approach
  • 1Use Date.today().addDays(-daysOld) to compute the cutoff date
  • 2Database.update(records, false) allows partial success — check results for failures
  • 3In finish(), use Messaging.SingleEmailMessage to send the summary email
  • 4UserInfo.getUserEmail() gets the running user's email address
Hard BatchGovernorError Handling

3. Batch Apex — Track Failed Record Updates

Problem #140 · Salesforce Apex Coding Challenge

Problem Statement

1,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:

  • Uses Database.update(records, false) (partial success) instead of the all-or-nothing DML.
  • Collects each failed record's Id and error message into a custom object Batch_Error_Log__c with fields Record_Id__c and Error_Message__c.
  • In finish(), sends an email summarising total records processed and failures.

Best Practices

  • Batch size: 200.
  • Do NOT re-throw exceptions — log them instead.
  • Test with Database.executeBatch() inside Test.startTest()/stopTest().
Approach
  • 1Database.update(scope, false) returns List.
  • 2Iterate results: if (!sr.isSuccess()) collect sr.getErrors()[0].getMessage().
  • 3Insert Batch_Error_Log__c records after collecting failures — one insert per execute() batch.
  • 4In finish(), query total Batch_Error_Log__c count and send via Messaging.SingleEmailMessage.
Medium Batch ApexAsync Apex

4. Batch Apex: Categorise Accounts by Annual Revenue Tier

Problem #204 · Salesforce Apex Coding Challenge

Problem Statement

Write a Batch Apex class AccountRevenueTierBatch that reads every Account and sets a custom field Revenue_Tier__c based on AnnualRevenue:

  • Enterprise — AnnualRevenue ≥ 1,000,000
  • Medium — AnnualRevenue 100,000 – 999,999
  • Small — AnnualRevenue < 100,000 or null

Requirements

  • Implement 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.

Best Practices

  • Treat null AnnualRevenue as 0 — never call arithmetic on null.
  • Guard DML: if (!toUpdate.isEmpty()) before updating.
  • Database.update(list, false) allows partial success — safe for bulk runs.
  • Never place SOQL or DML inside a loop.
Approach
  • 1Null-safe: Decimal rev = acc.AnnualRevenue != null ? acc.AnnualRevenue : 0;
  • 2Ternary chain: rev >= 1000000 ? 'Enterprise' : rev >= 100000 ? 'Medium' : 'Small'
  • 3Skip unchanged records: if (acc.Revenue_Tier__c != tier) { acc.Revenue_Tier__c = tier; toUpdate.add(acc); }
  • 4Partial-success DML: Database.update(toUpdate, false) — the false flag prevents all-or-nothing rollback.
Hard Batch ApexAsync Apex

5. Batch Apex: Deactivate Stale Contacts with Partial DML

Problem #205 · Salesforce Apex Coding Challenge

Problem Statement

Write StaleContactDeactivationBatch that marks Contacts as stale when they have had no activity for 24 months.

Requirements

  • Implement Database.Batchable<SObject> and Database.Stateful.
  • Declare instance variables 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.

Best Practices

  • Use Database.Stateful to accumulate counts across batches.
  • Inspect every Database.SaveResult — never silently swallow errors.
  • Cutoff date: Date.today().addMonths(-24).
Approach
  • 1Implement both Database.Batchable AND Database.Stateful to persist counts across batches.
  • 2Cutoff: Date cutoff = Date.today().addMonths(-24); — use a bind variable in the SOQL string.
  • 3Capture results: List results = Database.update(toUpdate, false);
  • 4Check each result: if (sr.isSuccess()) processedCount++; else { failedCount++; for (Database.Error err : sr.getErrors()) System.debug(err.getMessage()); }
Medium Batch ApexAsync Apex

6. Batch Apex: Sync Contact Title from Account Industry

Problem #219 · Salesforce Apex Coding Challenge

Problem Statement

Write ContactTitleSyncBatch that reads all Contacts with an Account and sets the Contact's Title field based on the parent Account's Industry.

Industry → Title Mapping

  • Technology'Technology Professional'
  • Finance'Finance Professional'
  • Healthcare'Healthcare Professional'
  • Any other'Industry Professional'

Requirements

  • Implement 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.

Best Practices

  • Use child-to-parent SOQL (Account.Industry) — avoids a second query.
  • Skip null industry with continue — defensive coding.
  • Wrap DML in try-catch to log errors without aborting the batch.
  • Only add records to the update list when the value actually changes.
Approach
  • 1Child-to-parent in query: SELECT Id, Title, Account.Industry FROM Contact WHERE AccountId != null
  • 2Read parent field: String industry = c.Account != null ? c.Account.Industry : null;
  • 3Skip null: if (industry == null) continue;
  • 4Wrap DML: try { update toUpdate; } catch (DmlException e) { System.debug(e.getMessage()); }
Hard Batch ApexAsync Apex

7. Batch Apex: Recalculate Account Engagement Score with Error Logging

Problem #220 · Salesforce Apex Coding Challenge

Problem Statement

Write AccountEngagementScoreBatch that calculates a custom Engagement_Score__c for every Account using three signals, and logs all errors via Database.Stateful.

Scoring Rules

  • +40 if AnnualRevenue ≥ 1,000,000
  • +30 if NumberOfEmployees ≥ 100
  • +30 if Account has at least one Contact (use a subquery)

Requirements

  • Implement Database.Batchable<SObject> and Database.Stateful.
  • Declare: 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.
Approach
  • 1Subquery in start(): '(SELECT Id FROM Contacts LIMIT 1)' — checks if at least one Contact exists.
  • 2Check subquery result: if (!acc.Contacts.isEmpty()) score += 30;
  • 3Per-record try-catch catches unexpected NullPointerExceptions during scoring.
  • 4SaveResult inspection: for (Database.SaveResult sr : results) { if (sr.isSuccess()) successCount++; else { errorCount++; errorLog.add(...); } }
Easy BatchAsync Apex

8. Batch Apex (Easy): Mark Stale Opportunities as Closed Lost

Problem #232 · Salesforce Apex Coding Challenge

Problem Statement

Write a batch class StaleOpportunityBatch that marks all Prospecting Opportunities that were created more than 365 days ago as Closed Lost.

Requirements

  1. Implement Database.Batchable<SObject>.
  2. start() — return a Database.QueryLocator for Opportunities WHERE StageName = 'Prospecting', IsClosed = false, CreatedDate <= :cutoff (cutoff = today minus 365 days).
  3. execute() — set StageName = 'Closed Lost' and CloseDate = Date.today(); save with Database.update(scope, false).
  4. finish() — log the job Id with System.debug.

Best Practices

  • Use Database.getQueryLocator() — not a List — so Salesforce paginates up to 50M rows.
  • Guard execute() with scope.isEmpty().
  • Database.update(scope, false) — partial saves: one bad record does not roll back the chunk.
Approach
  • 1start(): Date cutoff = Date.today().addDays(-365);
  • 2Locator: return Database.getQueryLocator([SELECT ... FROM Opportunity WHERE StageName = 'Prospecting' AND IsClosed = false AND CreatedDate <= :cutoff]);
  • 3execute(): for (Opportunity opp : scope) { opp.StageName = 'Closed Lost'; opp.CloseDate = Date.today(); }
  • 4Partial save: Database.update(scope, false) — allOrNone=false prevents one failure from rolling back the chunk.
  • 5finish(): System.debug('Complete. Job: ' + bc.getJobId());
Easy BatchAsync Apex

9. Batch Apex (Easy-Medium): Stateful Batch — Count Processed Leads

Problem #233 · Salesforce Apex Coding Challenge

Problem Statement

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.

Requirements

  1. Implement both Database.Batchable<SObject> and Database.Stateful.
  2. Declare private Integer processedCount = 0 and private Integer failedCount = 0 as instance variables.
  3. start() — query unconverted Leads with Lead_Score__c > 0.
  4. execute() — reset Lead_Score__c = 0, save with Database.update(scope, false), inspect Database.SaveResult[] to increment counters.
  5. finish() — log the totals.

Key Concept: Database.Stateful

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.

Approach
  • 1Database.Stateful interface keeps instance variables alive between execute() chunks.
  • 2Database.SaveResult[] results = Database.update(scope, false); — inspect each result.
  • 3SaveResult check: if (sr.isSuccess()) { processedCount++; } else { failedCount++; }
  • 4finish() can read processedCount directly because Stateful preserves it.
  • 5Run: Database.executeBatch(new LeadScoreResetBatch(), 200);
Medium BatchAsync Apex

10. Batch Apex (Medium): Contact Email Domain Enforcement with Partial DML

Problem #234 · Salesforce Apex Coding Challenge

Problem Statement

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.

Requirements

  1. Implement Database.Batchable<SObject> and Database.Stateful.
  2. Declare constants/fields: ALLOWED_DOMAIN, errorLog, successCount.
  3. start() — query Contacts WHERE Email != null.
  4. 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[].
  5. finish() — log success count and all error messages.

Best Practices

  • Use a local toUpdate list — don't update all scope records, only those that changed.
  • Guard with toUpdate.isEmpty() before DML — avoids unnecessary governor usage.
  • Use indexed access results[i] alongside toUpdate[i] to correlate errors back to the correct record Id.
Approach
  • 1Domain check: !c.Email.toLowerCase().endsWith('@' + ALLOWED_DOMAIN)
  • 2Only update records that changed — collect into toUpdate list before DML.
  • 3Correlate errors: for (Integer i = 0; i < results.size(); i++) { if (!results[i].isSuccess()) ... toUpdate[i].Id ... }
  • 4Database.Error: results[i].getErrors() returns List; each has getMessage().
  • 5String.isBlank() handles both null and empty string email values.
Medium BatchAsync Apex

11. Batch Apex (Medium): Sync Account Billing Address to Child Contacts

Problem #235 · Salesforce Apex Coding Challenge

Problem Statement

Write AccountAddressSyncBatch that copies each Account's billing address to all of its child Contacts' mailing address fields in bulk.

Requirements

  1. Implement Database.Batchable<SObject>.
  2. start() — query Accounts WHERE BillingCity != null.
  3. execute():
    • Collect Account Ids from the scope batch.
    • Build a Map<Id, Account> from scope for O(1) lookup.
    • Query all child Contacts in one SOQL: WHERE AccountId IN :accountIds.
    • Copy BillingCity → MailingCity, BillingState → MailingState, BillingPostalCode → MailingPostalCode, BillingCountry → MailingCountry.
    • Bulk update with Database.update(toUpdate, false).

Key Challenge

A SOQL inside execute() is allowed (one per chunk) — but it must be outside any loop. Build the Id set first, then query once.

Approach
  • 1Map from scope: Map accountMap = new Map(scope); — Map constructor accepts a List.
  • 2Child SOQL: [SELECT Id, AccountId, MailingCity... FROM Contact WHERE AccountId IN :accountIds]
  • 3Copy: c.MailingCity = acc.BillingCity; c.MailingState = acc.BillingState; etc.
  • 4One SOQL per execute() chunk is allowed — keep it outside the for loop.
  • 5Guard: if (!toUpdate.isEmpty()) Database.update(toUpdate, false);
Hard BatchAsync Apex

12. Batch Apex (Hard): Chained Batch — Revenue Rollup then Account Tier

Problem #236 · Salesforce Apex Coding Challenge

Problem Statement

Implement two chained batch classes:

  1. RevenueRollupBatch — calculates total closed-won revenue per Account (using a parent-child subquery), writes it to Total_Closed_Won_Revenue__c, then chains AccountTierAssignmentBatch in finish().
  2. AccountTierAssignmentBatch — assigns Account_Tier__c (Platinum/Gold/Silver/Bronze) based on the revenue field.

Chaining Rules

  • Guard chain in 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.
  • Skip records where the tier has not changed — only DML when needed.
Approach
  • 1Parent-child subquery: SELECT Id, (SELECT Amount FROM Opportunities WHERE IsClosed = true AND IsWon = true) FROM Account
  • 2Sum child records: for (Opportunity opp : acc.Opportunities) { if (opp.Amount != null) total += opp.Amount; }
  • 3Chain in finish(): if (!Test.isRunningTest()) { Database.executeBatch(new AccountTierAssignmentBatch(), 200); }
  • 4Tier logic: rev >= 1000000 ? 'Platinum' : rev >= 500000 ? 'Gold' : rev >= 100000 ? 'Silver' : 'Bronze'
  • 5Skip unchanged: if (acc.Account_Tier__c != tier) { acc.Account_Tier__c = tier; toUpdate.add(acc); }
Easy BatchAsync Apex

13. Schedulable (Easy): Weekly Batch Job Scheduler

Problem #237 · Salesforce Apex Coding Challenge

Problem Statement

Write WeeklyOpportunityCleanupScheduler that implements Schedulable and kicks off StaleOpportunityBatch on a scheduled basis.

Requirements

  • Implements Schedulable.
  • Declare a @TestVisible private Integer batchSize = 200 field.
  • execute(SchedulableContext sc) — creates a new StaleOpportunityBatch() and calls Database.executeBatch(batch, batchSize).

How to Schedule

String cron = '0 0 2 ? * MON *'; // Every Monday at 2 AM
String jobName = 'Weekly Opp Cleanup';
System.schedule(jobName, cron, new WeeklyOpportunityCleanupScheduler());

Best Practices

  • Store batch size in an @TestVisible field so tests can override it.
  • Keep execute() minimal — one line to create and enqueue the batch.
  • Salesforce CRON: Seconds Minutes Hours Day Month DayOfWeek Year.
Approach
  • 1Class signature: public class WeeklyOpportunityCleanupScheduler implements Schedulable
  • 2execute() signature: public void execute(SchedulableContext sc)
  • 3@TestVisible lets test classes set batchSize without exposing it publicly.
  • 4Batch kick-off: Database.executeBatch(new StaleOpportunityBatch(), batchSize);
  • 5Schedule via Apex: System.schedule('Weekly Opp Cleanup', '0 0 2 ? * MON *', new WeeklyOpportunityCleanupScheduler());
Medium BatchAsync Apex

14. Schedulable (Medium): Monthly Lead Summary Email

Problem #238 · Salesforce Apex Coding Challenge

Problem Statement

Write MonthlyLeadSummaryScheduler that implements Schedulable and emails a monthly report of new unconverted Leads to an admin.

Requirements

  1. Implements Schedulable.
  2. @TestVisible private String recipientEmail = 'admin@salesforce.com'.
  3. execute(SchedulableContext sc):
    • Query COUNT() of Leads WHERE IsConverted = false AND CreatedDate = THIS_MONTH.
    • Build a Messaging.SingleEmailMessage with subject and body.
    • Send via Messaging.sendEmail() guarded by !Test.isRunningTest().

Best Practices

  • Guard Messaging.sendEmail() with !Test.isRunningTest() — sending email in tests uses the daily email allowance.
  • Use the THIS_MONTH date literal — cleaner than calculating start/end dates.
  • Store the recipient in a @TestVisible field for testability.
Approach
  • 1SOQL COUNT: Integer leadCount = [SELECT COUNT() FROM Lead WHERE IsConverted = false AND CreatedDate = THIS_MONTH];
  • 2Email: Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
  • 3setToAddresses() takes List: email.setToAddresses(new List{ recipientEmail });
  • 4Guard sending: if (!Test.isRunningTest()) { Messaging.sendEmail(new List{ email }); }
  • 5Date format: Date.today().month() + '/' + Date.today().year()
Hard BatchAsync Apex

15. Schedulable (Hard): Self-Rescheduling Hourly Data Monitor

Problem #239 · Salesforce Apex Coding Challenge

Problem Statement

Write HourlyDataMonitorScheduler that runs every hour, counts new open Cases, alerts on spikes, and re-schedules itself for the next hour automatically.

Requirements

  1. Implements Schedulable.
  2. private static final String JOB_NAME = 'HourlyDataMonitor'.
  3. @TestVisible private static String buildNextHourCron() — builds a CRON expression for exactly one hour from now using DateTime.now().addHours(1) and String.format().
  4. execute():
    • Count Cases created in the last hour: CreatedDate >= :oneHourAgo.
    • Log count. If count > 50, log an alert.
    • Abort current job with System.abortJob(sc.getTriggerId()).
    • Re-schedule with System.schedule(JOB_NAME, buildNextHourCron(), new HourlyDataMonitorScheduler()).
    • Guard all scheduling code with !Test.isRunningTest().

Self-Rescheduling Pattern

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.

Approach
  • 1CRON format: '0 0 {hour} {day} {month} ? {year}' — second, minute, hour, day, month, day-of-week(?), year.
  • 2String.format: String.format('0 0 {0} {1} {2} ? {3}', new List{ nextHour.hour(), nextHour.day(), nextHour.month(), nextHour.year() })
  • 3Abort current: System.abortJob(sc.getTriggerId()); — prevents the scheduler from firing the old CRON again.
  • 4Re-schedule: System.schedule(JOB_NAME, buildNextHourCron(), new HourlyDataMonitorScheduler());
  • 5Always guard scheduling code: if (!Test.isRunningTest()) { ... } — prevents test failures from scheduling conflicts.
  • Easy BatchAsync Apex

    16. Batch Apex (Easy): Deactivate Users Inactive for 90+ Days

    Problem #240 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements — Batch Class

    1. Implement Database.Batchable<SObject>.
    2. start() — return a Database.QueryLocator for Users WHERE IsActive = true AND LastLoginDate <= :cutoff (cutoff = today minus 90 days).
    3. execute() — set IsActive = false on each User; save with Database.update(scope, false).
    4. finish() — log the Job Id with System.debug.

    Requirements — Test Class

    • Use @isTest annotation and Test.startTest() / Test.stopTest().
    • Test that Database.executeBatch() runs without error.
    • Test that start() returns a non-null QueryLocator.
    • Test that execute() handles an empty scope gracefully.
    Approach
    • 1Cutoff: Date cutoff = Date.today().addDays(-90);
    • 2Query: SELECT Id, IsActive, LastLoginDate FROM User WHERE IsActive = true AND LastLoginDate <= :cutoff
    • 3execute(): for (User u : scope) { u.IsActive = false; } then Database.update(scope, false)
    • 4Test batch: Test.startTest(); Database.executeBatch(new InactiveUserDeactivateBatch(), 200); Test.stopTest();
    • 5Test start(): Database.QueryLocator ql = batch.start(null); System.assertNotEquals(null, ql, ...);
    Easy BatchAsync Apex

    17. Batch Apex (Easy-Medium): Stateful Batch — Escalate Stale Cases

    Problem #241 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements — Batch Class

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer escalatedCount = 0 and private Integer skippedCount = 0.
    3. start() — query Cases WHERE Status != 'Closed' AND CreatedDate <= :cutoff (7 days ago).
    4. execute() — skip already-High cases (increment skippedCount); set Priority = 'High' on others and increment escalatedCount on success.
    5. finish() — log both counters.

    Requirements — Test Class

    • Use @TestSetup to insert test Cases (some Low priority, one already High).
    • Test that non-High cases are escalated to High after the batch runs.
    • Test that already-High cases are unchanged.
    • Use Test.startTest() / Test.stopTest() around Database.executeBatch().
    Approach
    • 1Stateful: implements Database.Batchable, Database.Stateful
    • 2Skip check: if (c.Priority == 'High') { skippedCount++; continue; }
    • 3Escalate: c.Priority = 'High'; toUpdate.add(c);
    • 4Test setup: @TestSetup static void makeData() — insert Cases before each test method.
    • 5Test run: Test.startTest(); Database.executeBatch(new CaseEscalationBatch(), 200); Test.stopTest();
    Medium BatchAsync Apex

    18. Batch Apex (Medium): Create Renewal Reminder Tasks for Expiring Contracts

    Problem #242 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer createdCount = 0 and private Integer errorCount = 0.
    3. start() — query Contracts WHERE EndDate = NEXT_N_DAYS:30 AND Status = 'Activated'.
    4. execute() — for each Contract, create a Task with:
      • Subject = 'Contract Renewal: ' + con.ContractNumber
      • WhatId = con.Id
      • ActivityDate = Date.today().addDays(1)
      • Status = 'Not Started', Priority = 'High'
      Use Database.insert(toInsert, false) and inspect SaveResult[].
    5. finish() — log both counters.

    Key Concept: NEXT_N_DAYS Date Literal

    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.

    Approach
    • 1Date literal: EndDate = NEXT_N_DAYS:30 — no Apex date variable needed.
    • 2Task fields: t.Subject = 'Contract Renewal: ' + con.ContractNumber; t.WhatId = con.Id;
    • 3Activity date: t.ActivityDate = Date.today().addDays(1);
    • 4DML: Database.SaveResult[] results = Database.insert(toInsert, false);
    • 5Error detail: for (Database.Error err : sr.getErrors()) System.debug(err.getMessage());

    Halfway there — solve them live

    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 →
    Hard BatchAsync Apex

    19. Batch Apex (Medium-Hard): Reassign Opportunities from Inactive Users

    Problem #243 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Id defaultOwnerId and private Integer reassignedCount = 0.
    3. Constructor accepts Id defaultOwnerId and stores it via this.defaultOwnerId.
    4. start() — query Opportunities WHERE IsClosed = false AND Owner.IsActive = false (cross-object filter).
    5. execute() — set OwnerId = defaultOwnerId on each Opportunity. Use Database.update(toUpdate, false) and inspect SaveResult[].
    6. finish() — log reassignedCount.

    Key Concept: Cross-Object SOQL Filter

    Owner.IsActive = false in the WHERE clause traverses the User relationship directly in SOQL — no Apex-side filtering needed.

    Approach
    • 1Cross-object filter: WHERE IsClosed = false AND Owner.IsActive = false — Salesforce resolves the User relationship.
    • 2Constructor: public OpportunityOwnerReassignBatch(Id defaultOwnerId) { this.defaultOwnerId = defaultOwnerId; }
    • 3Reassign: opp.OwnerId = defaultOwnerId; toUpdate.add(opp);
    • 4SaveResult: Database.SaveResult[] results = Database.update(toUpdate, false);
    • 5Run: Database.executeBatch(new OpportunityOwnerReassignBatch(someUserId), 200);
    Hard BatchAsync Apex

    20. Batch Apex (Hard): Calculate Account Health Score via Subquery Rollup

    Problem #244 · Salesforce Apex Coding Challenge

    Problem Statement

    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().

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer processedCount = 0 and private Integer errorCount = 0.
    3. start() — use a parent-with-child subquery to retrieve each Account's open Opportunities AND open Cases in one query.
    4. execute():
      • Read: acc.Cases.size() and acc.Opportunities.size().
      • Formula: healthScore = 100 - (openCaseCount * 10) + (openOppCount * 5)
      • Clamp: Math.max(0, Math.min(100, healthScore)).
      • Save to Account_Health_Score__c; increment processedCount.
    5. finish() — log both counters.

    Key Concept: Parent-Child Subquery in Batch start()

    Placing a subquery inside Database.getQueryLocator fetches related child records for each parent in the same SOQL, completely avoiding SOQL-in-loop violations.

    Approach
    • 1Subquery: (SELECT Amount FROM Opportunities WHERE IsClosed = false AND Amount != null)
    • 2Read child list: Integer openCaseCount = acc.Cases.size(); — subquery results are Lists on the parent.
    • 3Formula: Integer healthScore = 100 - (openCaseCount * 10) + (openOppCount * 5);
    • 4Clamp: healthScore = Math.max(0, Math.min(100, healthScore));
    • 5Custom field: acc.Account_Health_Score__c = healthScore; — assumes field exists on Account.
    Medium Batch Apex

    21. Batch Apex: Tag Accounts With No Contacts or Opportunities

    Problem #290 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class NoActivityAccountBatch that sets Description to 'Needs Review' on every Account that has no Contacts and no Opportunities.

    Requirements

    • Implement Database.Batchable<SObject>
    • start() — query ALL accounts with subqueries for Contacts and Opportunities (LIMIT 1 each)
    • execute() — check both subquery lists, update only qualifying accounts
    • Guard DML with an isEmpty() check
    • Use Database.update(list, false) for partial-DML safety
    • finish() — log completion with System.debug
    • Store the flag value 'Needs Review' in a named constant
    Approach
    • 1Use subqueries inside the SELECT: (SELECT Id FROM Contacts LIMIT 1) and (SELECT Id FROM Opportunities LIMIT 1).
    • 2In execute(), access them as acc.Contacts and acc.Opportunities — check isEmpty().
    • 3Database.update(list, false) allows partial success; failed records are skipped.
    • 4Always guard the DML call with if (!toUpdate.isEmpty()).
    Medium Batch Apex

    22. Batch Apex: Create Follow-Up Tasks for Overdue Open Opportunities

    Problem #291 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class OverdueOppFollowUpBatch that creates a follow-up Task for every open Opportunity whose CloseDate is in the past.

    Requirements

    • Implement Database.Batchable<SObject>
    • start() — query open opportunities with IsClosed = false AND CloseDate < TODAY
    • execute() — create one Task per opportunity:
      • Subject: 'Follow Up: ' + opp.Name
      • WhatId: opportunity Id
      • OwnerId: opportunity owner
      • ActivityDate: today + 3 days (use a constant)
      • Status = 'Not Started', Priority = 'High'
    • Use Database.insert(tasks, false) with isEmpty() guard
    • Store the due-day offset in a named constant
    Approach
    • 1Filter with IsClosed = false AND CloseDate < TODAY to find overdue open opportunities.
    • 2WhatId links the Task to the Opportunity record (activity relation).
    • 3Date.today().addDays(TASK_DUE_DAYS) computes the due date at runtime.
    • 4Database.insert(tasks, false) allows partial success if individual task inserts fail.
    Medium Batch Apex

    23. Batch Apex: Stateful — Count How Many Contacts Were Updated

    Problem #292 · Salesforce Apex Coding Challenge

    Problem Statement

    Write 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.

    Requirements

    • Implement both Database.Batchable<SObject> and Database.Stateful
    • Declare a global Integer totalUpdated = 0 instance variable
    • start() — query Contacts where Title != null
    • execute() — trim, lower-case, then capitalise the first character; skip records already correct
    • Accumulate totalUpdated only when records are actually changed
    • finish() — log totalUpdated with System.debug
    Approach
    • 1Implement Database.Stateful to preserve instance variable values across execute() calls.
    • 2Sentence-case: str.trim().toLowerCase() then str.substring(0,1).toUpperCase() + str.substring(1).
    • 3Only increment totalUpdated by toUpdate.size() — not scope.size().
    • 4Guard the update with isEmpty() before calling Database.update(toUpdate, false).
    Medium Batch Apex

    24. Batch Apex: Auto-Close Cases Waiting on Customer for 14+ Days

    Problem #293 · Salesforce Apex Coding Challenge

    Problem Statement

    Write 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.

    Requirements

    • Implement Database.Batchable<SObject>
    • Store thresholds in named constants: STALE_DAYS = 14, WAITING = 'Waiting on Customer', CLOSED_STATUS = 'Closed'
    • start() — compute cutoff as Date.today().addDays(-STALE_DAYS); filter Status = :WAITING AND LastModifiedDate <= :cutoff
    • execute() — set Status = 'Closed' and Description to a closure reason; use Database.update(list, false)
    • finish() — log completion
    Approach
    • 1Compute the cutoff date before the query string: Date cutoff = Date.today().addDays(-STALE_DAYS).
    • 2Use :WAITING and :cutoff as bind variables inside the SOQL string — they resolve from class scope.
    • 3LastModifiedDate <= :cutoff catches records not modified for 14 or more days.
    • 4Always use Database.update(list, false) in batches to avoid one bad record failing the entire chunk.
    Hard Batch Apex

    25. Batch Apex: Reassign Uncontacted Leads Older Than 30 Days to a Queue

    Problem #294 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class UncontactedLeadReassignBatch that reassigns stale, uncontacted Lead records to a Salesforce Queue named 'Uncontacted_Leads'.

    Requirements

    • Implement Database.Batchable<SObject>
    • In the constructor, query the Group object (type = 'Queue') for the queue Id — store as an instance variable
    • Store constants: STALE_DAYS = 30, QUEUE_NAME = 'Uncontacted_Leads', STATUS_OPEN = 'Open - Not Contacted'
    • start() — query unconverted leads with the open status created more than 30 days ago
    • execute() — if queue found, set OwnerId = queueId; use Database.update(list, false)
    • finish() — log completion
    Approach
    • 1Query Group WHERE Type = 'Queue' AND DeveloperName = :QUEUE_NAME in the constructor to resolve the queue Id once.
    • 2Guard execute() with if (queueId == null) return; so the batch is a no-op when the queue does not exist.
    • 3Use IsConverted = false to exclude already-converted leads.
    • 4CreatedDate <= :cutoff (not LastModifiedDate) targets leads that are old, not just unmodified.
    Easy Batch Apex

    26. Batch Apex: Update Inactive Contacts

    Problem #352 · Salesforce Apex Coding Challenge

    Problem Statement

    Write 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.

    Requirements

    • Implement Database.Batchable<SObject> with all three methods.
    • Provide a no-arg constructor (defaults to 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.

    Best Practices

    • Mutate all records in the for loop, then call update scope once outside it.
    • Wrap DML in try/catch(DmlException).
    • Never place SOQL or DML inside a loop.
    Approach
    • 1The :cutoffDate bind variable in your SOQL string refers to the instance field declared at the class level — Apex resolves it automatically.
    • 2Mutate each Contact inside the loop; place update scope outside the loop after iteration ends.
    • 3Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); } to handle partial failures.
    • 4In finish(), System.debug('Job finished. Id: ' + bc.getJobId()) prints the result to the Output (Console) tab.
    Easy Batch Apex

    27. Batch Apex: Delete Duplicate Leads by Email

    Problem #353 · Salesforce Apex Coding Challenge

    Problem Statement

    Write DeleteDuplicateLeadsBatch that removes duplicate unconverted Leads, keeping the most recently created Lead per email address.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Declare 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).
    • Inspect every Database.DeleteResult; increment totalDeleted on success.
    • finish(): System.debug the total deleted count — visible in the Output tab.

    Best Practices

    • Use allOrNone = false so one bad record does not abort the entire delete.
    • Always inspect DeleteResult errors rather than swallowing them silently.
    • Guard DML with if (!toDelete.isEmpty()).
    Approach
    • 1Both interfaces on one line: implements Database.Batchable, Database.Stateful
    • 2Grouping: Map> emailToLeads — key is l.Email.toLowerCase() for case-insensitive match.
    • 3The newest lead is at index 0 because ORDER BY CreatedDate DESC puts it first. Delete indices 1, 2, ... into toDelete.
    • 4Database.delete(toDelete, false): false = allOrNone = false. Iterate results: if (!dr.isSuccess()) log dr.getErrors().
    Easy Batch Apex

    28. Batch Apex: Archive Closed Opportunities Older Than 2 Years

    Problem #354 · Salesforce Apex Coding Challenge

    Problem Statement

    Write ArchiveClosedOpportunitiesBatch that stamps the Description field of all Closed Won or Closed Lost Opportunities whose CloseDate is older than 2 years.

    Requirements

    • Implement Database.Batchable<SObject>.
    • Overloaded constructors: no-arg (cutoff = 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.
    Approach
    • 1Define Set closedStages = new Set{ 'Closed Won', 'Closed Lost' } inside start(), then use WHERE StageName IN :closedStages.
    • 2Use Date.today().addYears(-2) for the default 2-year cutoff date.
    • 3Stamp Description for every opp inside the loop; call update scope once after the loop ends.
    • 4Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); }
    Medium Batch Apex

    29. Batch Apex: Update Account Ratings by Annual Revenue

    Problem #355 · Salesforce Apex Coding Challenge

    Problem Statement

    Write UpdateAccountRatingsBatch that reads every Account and sets the Rating picklist field based on AnnualRevenue:

    • Hot — AnnualRevenue ≥ 1,000,000,000
    • Warm — AnnualRevenue ≥ 100,000,000 and < 1,000,000,000
    • Cold — AnnualRevenue < 100,000,000 or null

    Requirements

    • Implement Database.Batchable<SObject>.
    • Declare both thresholds as 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.
    Approach
    • 1Null-safe revenue: Decimal revenue = acct.AnnualRevenue != null ? acct.AnnualRevenue : 0;
    • 2Threshold chain: if (revenue >= HOT_THRESHOLD) acct.Rating = 'Hot'; else if (revenue >= WARM_THRESHOLD) acct.Rating = 'Warm'; else acct.Rating = 'Cold';
    • 3Mutate Rating inside the loop, call update scope once after.
    • 4private static final Decimal constants avoid magic numbers.
    Medium Batch Apex

    30. Batch Apex: Yearly Sales Performance Aggregation (Stateful)

    Problem #356 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Declare private Map<Id, Decimal> accountAmountMap to accumulate totals.
    • execute(): accumulate Amountno DML here.
    • finish(): bulk update Accounts; System.debug the count — visible in the Output tab.
    Approach
    • 1Implement both: implements Database.Batchable, Database.Stateful
    • 2Accumulation: Decimal current = accountAmountMap.containsKey(id) ? accountAmountMap.get(id) : 0; accountAmountMap.put(id, current + amount);
    • 3Year bounds: Integer yr = Date.today().year(); Date yearStart = Date.newInstance(yr, 1, 1); Date yearEnd = Date.newInstance(yr, 12, 31);
    • 4All DML goes in finish() — zero DML in execute().
    Medium Batch Apex

    31. Batch Apex: Mark Inactive Accounts with No Open Opportunities

    Problem #359 · Salesforce Apex Coding Challenge

    Problem Statement

    Write InactiveAccountsBatch that marks Accounts as inactive when they have not been modified in 5 years AND have no open Opportunities.

    Requirements

    • Implement Database.Batchable<SObject>.
    • Overloaded constructors (no-arg defaults to 5 years ago; overload accepts custom DateTime).
    • 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.
    Approach
    • 1Anti-join pattern: WHERE Id NOT IN (SELECT AccountId FROM Opportunity WHERE IsClosed = false AND AccountId != null)
    • 2Use DateTime (not Date) — LastModifiedDate is a DateTime field.
    • 3DateTime.now().addYears(-5) gives the cutoff 5 years ago.
    • 4Both conditions must be met: old LastModifiedDate AND no open opportunities.
    Medium Batch Apex

    32. Batch Apex: Archive Closed Cases Older Than 2 Years

    Problem #360 · Salesforce Apex Coding Challenge

    Problem Statement

    Write ArchiveClosedCasesBatch that stamps the Description of all closed Cases whose ClosedDate is older than 2 years.

    Requirements

    • Implement Database.Batchable<SObject>.
    • No-arg constructor (cutoff = 2 years ago); overloaded constructor accepting custom DateTime.
    • 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.
    Approach
    • 1IsClosed is a read-only boolean on Case — use WHERE IsClosed = true.
    • 2ClosedDate is a DateTime field — use DateTime.now().addYears(-2) for the cutoff.
    • 3Stamp each Case inside the loop, then update scope once after.
    • 4Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); }
    Medium Batch Apex

    33. Batch Apex: Update Opportunity Probability by Stage Name

    Problem #361 · Salesforce Apex Coding Challenge

    Problem Statement

    Write UpdateOpportunityProbabilityBatch that sets Probability for every Opportunity based on StageName using a static map.

    Stage → Probability

    • Prospecting→10, Qualification→20, Needs Analysis→30, Value Proposition→40
    • Id. Decision Makers→50, Perception Analysis→60, Proposal/Price Quote→70, Negotiation/Review→80
    • Closed Won→100, Closed Lost→0

    Requirements

    • Declare the map as 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.
    Approach
    • 1In start(): Set stages = STAGE_PROBABILITY_MAP.keySet(); — use :stages as a bind variable.
    • 2Skip unknown stages: Integer newProb = STAGE_PROBABILITY_MAP.get(opp.StageName); if (newProb != null) { ... }
    • 3Guard DML: if (!toUpdate.isEmpty()) { try { update toUpdate; } catch (DmlException e) { ... } }
    • 4static final constants avoid magic numbers — define them once, reuse everywhere.
    Medium Batch Apex

    34. Batch Apex: Round-Robin Lead Assignment (Stateful)

    Problem #362 · Salesforce Apex Coding Challenge

    Problem Statement

    Write MassLeadAssignmentBatch that assigns all open non-converted Leads to a list of owners in round-robin order across all batch chunks.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Constructor accepts List<Id> ownerIds; throws IllegalArgumentException if null/empty.
    • Stateful assignmentIndex = 0 persists across chunks.
    • execute(): Math.mod(assignmentIndex, ownerCount); bulk update outside loop.
    • finish(): System.debug total assigned — visible in the Output tab.
    Approach
    • 1Round-robin: l.OwnerId = ownerIds[Math.mod(assignmentIndex, ownerIds.size())]; assignmentIndex++;
    • 2Database.Stateful is required — assignmentIndex must persist across separate execute() calls.
    • 3Validate: if (ownerIds == null || ownerIds.isEmpty()) throw new IllegalArgumentException(...);
    • 4Cache ownerIds.size() before the loop: Integer ownerCount = ownerIds.size();
    Medium Batch Apex

    35. Batch Apex: Clean Duplicate Contacts by Account and Email

    Problem #363 · Salesforce Apex Coding Challenge

    Problem Statement

    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.

    Requirements

    • Implement 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.
    Approach
    • 1Composite key: String key = c.AccountId + '|' + c.Email.toLowerCase();
    • 2ORDER BY AccountId, Email, CreatedDate ASC puts the oldest contact first (index 0 = keeper).
    • 3Database.delete(toDelete, false): allOrNone=false. Inspect each DeleteResult.isSuccess().
    • 4Contacts from different Accounts with the same Email are NOT duplicates — the AccountId prefix keeps them separate.

    Practice All 35 Problems Free

    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 Problems
    About ApexArena

    ApexArena 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