Apex Data Types and Collections: Complete Guide for Developers

If you’ve spent any time building on Salesforce, you know that understanding Apex data types isn’t just about memorizing syntax—it’s about writing code that’s efficient, maintainable, and doesn’t blow up your governor limits. Whether you’re handling a simple string or orchestrating complex collections across thousands of records, the choices you make with data types directly impact your application’s performance.

Here’s what makes this tricky: Apex gives you primitive types, sObjects, collections, and enums, but knowing when to use each one separates junior developers from architects. In this guide, I’ll walk you through everything you need to know about Apex data types and Salesforce collections, with real-world examples that actually matter in enterprise applications.

Apex Data Type Hierarchy Diagram

What Are Primitive Data Types in Apex?

Primitive data types are the building blocks of your Apex code. Think of them as the basic ingredients before you start cooking up complex business logic.

Integer handles whole numbers without decimals. You’ll use this for counters, record limits, or any scenario where fractional values don’t make sense. The range? -2,147,483,648 to 2,147,483,647.

Apex

Integer recordCount = 150;
Integer maxRetries = 3;

Long steps in when Integer isn’t enough. I’ve seen developers hit Integer limits when processing large data volumes or working with timestamps. Long extends that range significantly.

Double and Decimal both handle decimal numbers, but here’s the crucial difference: use Decimal for currency and financial calculations. Why? Decimal provides exact precision, while Double uses floating-point arithmetic that can introduce rounding errors. In enterprise applications, those tiny errors compound into real problems.

Apex

Decimal unitPrice = 29.99;
Decimal totalAmount = unitPrice * 100; // Exact calculation

String represents text, and you’ll use it constantly. One thing I’ve learned: always assume strings might be null. Check before you call methods like substring() or contains().

Boolean gives you true/false values. Clean, simple, essential for control flow and flag management.

Date, Time, and Datetime handle temporal data. Use Date for birthdays and deadlines, Datetime for timestamps with time zones, and Time when you only need the hour/minute/second component.

ID is Salesforce’s unique record identifier type. It automatically handles both 15 and 18-character ID formats, which is why you should use ID instead of String for record references.

Blob stores binary data—PDFs, images, encrypted content. You won’t use it daily, but when you need it, nothing else works.

Understanding sObject Types in Apex

An sObject in Apex represents a Salesforce object—either standard or custom. Every Account, Contact, or CustomObject__c you work with is an sObject instance.

Here’s what makes sObjects powerful: they’re not just data containers. They understand Salesforce’s metadata, respect field-level security, and integrate seamlessly with DML operations.

Apex

Account acc = new Account();
acc.Name = 'Acme Corporation';
acc.Industry = 'Technology';
insert acc;

Generic sObject vs. Specific Types

You can declare variables as the generic sObject type or as specific types like Account or Contact. I typically use specific types because you get compile-time type checking and better IDE support.

Apex

sObject obj = new Account(Name = 'Test'); // Generic
Account acc = new Account(Name = 'Test'); // Specific - better

The generic approach makes sense when you’re building dynamic code that works across multiple object types. But for standard CRUD operations, stick with specific types.

Field Access and Relationships

sObjects let you traverse relationships using dot notation. Parent relationships go up, child relationships come back as lists.

Apex

Contact con = [SELECT FirstName, Account.Name FROM Contact LIMIT 1];
String companyName = con.Account.Name; // Parent relationship

Account acc = [SELECT Name, (SELECT LastName FROM Contacts) FROM Account LIMIT 1];
List<Contact> contacts = acc.Contacts; // Child relationship
Parent Child Queries in Salesforce Apex

Mastering Apex Collections: List, Set, and Map

Collections are where Apex development gets interesting. Understanding when to use each collection type will make your code faster and more elegant.

Apex List Collections

An Apex List is an ordered collection that allows duplicates. Think of it as an array with superpowers—dynamic sizing, built-in methods, and index-based access.

Apex

List<String> productNames = new List<String>();
productNames.add('Widget');
productNames.add('Gadget');
productNames.add('Widget'); // Duplicates allowed

String firstProduct = productNames[0]; // Index access
Integer size = productNames.size();

When to use List: When order matters, when you need index-based access, or when duplicates are acceptable. SOQL queries return Lists, so you’ll work with them constantly.

Lists are perfect for bulk operations. I’ve seen developers process thousands of records efficiently by leveraging List methods in loops.

Salesforce Set Collections

A Set stores unique, unordered elements. No duplicates, ever. This makes Sets incredibly useful for deduplication and membership testing.

Apex

Set<Id> accountIds = new Set<Id>();
accountIds.add('001xx000003DGb0AAG');
accountIds.add('001xx000003DGb0AAG'); // Silently ignored

if (accountIds.contains('001xx000003DGb0AAG')) {
    // Fast lookup - O(1) complexity
}

When to use Set: When you need unique values, when you’re checking membership frequently, or when order doesn’t matter. Sets outperform Lists for contains() operations on large datasets.

I use Sets all the time for collecting unique IDs from records before querying related data. It’s cleaner than manually checking for duplicates.

Apex Map Collections

A Map stores key-value pairs, giving you fast lookups by key. This is your go-to structure for associating data and building lookup tables.

Apex

Map<Id, Account> accountMap = new Map<Id, Account>([
    SELECT Id, Name, Industry FROM Account
]);

Account acc = accountMap.get(someId); // Direct access by ID

Map with Custom Keys

You’re not limited to ID keys. Any primitive type or String works, enabling powerful patterns:

Apex

Map<String, List<Contact>> contactsByLastName = new Map<String, List<Contact>>();

for (Contact con : contacts) {
    if (!contactsByLastName.containsKey(con.LastName)) {
        contactsByLastName.put(con.LastName, new List<Contact>());
    }
    contactsByLastName.get(con.LastName).add(con);
}

This pattern—grouping records by a field value—appears constantly in real applications. Master it.

Essential Collection Methods and Operations

Salesforce collections come with methods that make data manipulation straightforward. Let me show you the ones you’ll actually use.

List Methods

  • add(element) – Append to the end
  • add(index, element) – Insert at specific position
  • get(index) – Retrieve by position
  • remove(index) – Delete by position
  • clear() – Empty the entire list
  • size() – Get element count
  • isEmpty() – Check if empty
  • sort() – Order elements

Set Methods

  • add(element) – Add unique element
  • contains(element) – Check membership
  • remove(element) – Delete element
  • addAll(collection) – Add multiple elements
  • retainAll(collection) – Keep only matching elements

Map Methods

  • put(key, value) – Add or update entry
  • get(key) – Retrieve value by key
  • containsKey(key) – Check if key exists
  • keySet() – Get all keys as Set
  • values() – Get all values as List
  • remove(key) – Delete entry

Here’s a practical example combining multiple methods:

Apex

List<Opportunity> opps = [SELECT Id, StageName, Amount FROM Opportunity];
Set<String> stages = new Set<String>();
Map<String, Decimal> revenueByStage = new Map<String, Decimal>();

for (Opportunity opp : opps) {
    stages.add(opp.StageName);
    
    if (!revenueByStage.containsKey(opp.StageName)) {
        revenueByStage.put(opp.StageName, 0);
    }
    
    Decimal current = revenueByStage.get(opp.StageName);
    revenueByStage.put(opp.StageName, current + opp.Amount);
}

This code analyzes opportunities by stage—something you might do in a dashboard component or reporting feature.

Working with Generic Collections in Apex

Generic collections let you specify the data type they’ll hold, giving you type safety and cleaner code. You’ve already seen this in action with List<Account> and Map<Id, Contact>.

The syntax uses angle brackets: Collection<DataType>. This prevents runtime errors from type mismatches and makes your intentions clear.

Apex

// Without generics (old, avoid this)
List accountList = new List();
accountList.add(new Account());
Account acc = (Account)accountList[0]; // Requires casting

// With generics (modern, correct)
List<Account> accountList = new List<Account>();
accountList.add(new Account());
Account acc = accountList[0]; // No casting needed

Nested Collections

You can nest collections for complex data structures:

Apex

Map<String, List<Contact>> contactsByAccount = new Map<String, List<Contact>>();
Map<Id, Set<String>> tagsByRecord = new Map<Id, Set<String>>();
List<List<String>> matrix = new List<List<String>>();

I’ve used nested Maps extensively in integration scenarios where you’re organizing data from external systems into hierarchical structures before processing.

Type Casting and Conversion in Apex

Type casting converts one data type to another. Apex handles some conversions implicitly, but others require explicit casting.

Implicit Conversion

Apex automatically converts smaller types to larger ones when there’s no data loss risk:

Apex

Integer count = 100;
Long bigCount = count; // Automatic
Decimal price = count; // Automatic

Explicit Casting

When converting could lose data or precision, you must cast explicitly:

Apex

Decimal price = 99.99;
Integer dollars = (Integer)price; // Results in 99

Long bigNumber = 5000000000L;
Integer smallNumber = (Integer)bigNumber; // Risky if value too large

String Conversions

Converting to and from strings happens constantly in integrations and user interfaces:

Apex

String numText = '42';
Integer num = Integer.valueOf(numText);

Decimal amount = 150.75;
String amountText = String.valueOf(amount);

Date and Datetime Conversions

Apex

Date today = Date.today();
Datetime now = Datetime.newInstance(today, Time.newInstance(0, 0, 0, 0));

Datetime rightNow = Datetime.now();
Date justDate = rightNow.date();

sObject Casting

When working with generic sObjects, you’ll need to cast to specific types:

Apex

sObject obj = [SELECT Id FROM Account LIMIT 1];
Account acc = (Account)obj; // Cast to specific type

One gotcha I’ve encountered: casting an sObject to the wrong type throws a runtime exception. Always verify the object type first if there’s any uncertainty.

Enumerations in Apex

Apex enums define a fixed set of named values, making your code more readable and less error-prone than string constants.

Apex

public enum Season { WINTER, SPRING, SUMMER, FALL }

Season currentSeason = Season.SUMMER;

if (currentSeason == Season.SUMMER) {
    // Season-specific logic
}

Why Use Enums?

Enums prevent typos and invalid values. Compare these approaches:

Apex

// Bad: String constants
String status = 'ACTIV'; // Typo, runtime error

// Good: Enum
public enum Status { ACTIVE, INACTIVE, PENDING }
Status status = Status.ACTIVE; // Compile-time validation

Common Use Cases

I use enums for status values, configuration options, and any scenario with a fixed set of choices. They’re particularly valuable in trigger handlers and service layer classes where you’re managing state transitions.

Apex

public enum OpportunityStage {
    PROSPECTING,
    QUALIFICATION,
    PROPOSAL,
    NEGOTIATION,
    CLOSED_WON,
    CLOSED_LOST
}

Enum Methods

Enums come with built-in methods:

  • name() – Returns the enum constant name as a string
  • ordinal() – Returns the position (starting at 0)
  • values() – Returns all enum values

Apex

Season s = Season.SUMMER;
String name = s.name(); // 'SUMMER'
Integer position = s.ordinal(); // 2
List<Season> allSeasons = Season.values();

Best Practices for Data Handling in Apex

After years of building enterprise Salesforce applications, here are the practices that consistently produce maintainable, performant code.

Choose the Right Collection Type

Use List when order matters or you need index access. Use Set for unique values and fast membership testing. Use Map when you need key-based lookups. Don’t default to List for everything.

Initialize Collections Before Use

Always initialize collections before adding elements:

Apex

List<Account> accounts = new List<Account>(); // Good
accounts.add(newAccount);

List<Account> accounts; // Bad - null pointer exception
accounts.add(newAccount); // Crash

Bulkify Your Collection Usage

Process records in collections, not one at a time. This is fundamental to staying within governor limits:

Apex

// Bad: Single record processing
for (Account acc : accounts) {
    update acc; // DML in loop - governor limit violation
}

// Good: Bulk processing
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : accounts) {
    accountsToUpdate.add(acc);
}
update accountsToUpdate; // Single DML operation

Use Specific Types Over Generic sObjects

Specific types catch errors at compile time and improve code clarity:

Apex

List<Account> accounts = new List<Account>(); // Better IDE support
List<sObject> objects = new List<sObject>(); // Avoid unless necessary

Null Check Collections and Data

Don’t assume collections or field values exist:

Apex

if (accounts != null && !accounts.isEmpty()) {
    for (Account acc : accounts) {
        if (String.isNotBlank(acc.Name)) {
            // Process safely
        }
    }
}

Leverage Map-Based Lookups

Convert Lists to Maps for repeated lookups:

Apex

// Inefficient: Nested loops
for (Contact con : contacts) {
    for (Account acc : accounts) {
        if (con.AccountId == acc.Id) {
            // Found match
        }
    }
}

// Efficient: Map lookup
Map<Id, Account> accountMap = new Map<Id, Account>(accounts);
for (Contact con : contacts) {
    Account acc = accountMap.get(con.AccountId);
    if (acc != null) {
        // Process match
    }
}

Use Sets for Deduplication

When collecting IDs or ensuring uniqueness, Set handles this automatically:

Apex

Set<Id> uniqueIds = new Set<Id>();
for (Opportunity opp : opportunities) {
    uniqueIds.add(opp.AccountId);
}
// uniqueIds now contains only unique Account IDs

Document Complex Type Conversions

When casting or converting types, explain why:

Apex

// Convert to Integer for comparison with external system
// which doesn't support Decimal precision
Integer roundedAmount = (Integer)opportunity.Amount;

Consider Memory and CPU Limits

Large collections consume heap space. In extreme cases (processing tens of thousands of records), you might need batch processing instead of handling everything in memory.

In my experience, most governor limit issues trace back to poor collection handling—DML in loops, inefficient querying, or building massive data structures unnecessarily.

Common Pitfalls and How to Avoid Them

Modifying Collections During Iteration

Don’t add or remove elements while iterating:

Apex

// This causes unpredictable behavior
for (Account acc : accounts) {
    if (acc.Active__c) {
        accounts.remove(acc); // Don't do this
    }
}

// Instead, use a separate collection
List<Account> accountsToRemove = new List<Account>();
for (Account acc : accounts) {
    if (!acc.Active__c) {
        accountsToRemove.add(acc);
    }
}
accounts.removeAll(accountsToRemove);

Assuming Collections Maintain Reference Equality

Creating a new List from an existing List creates a shallow copy:

Apex

List<Account> original = new List<Account>{new Account(Name = 'Test')};
List<Account> copy = original.clone();

copy[0].Name = 'Modified';
System.debug(original[0].Name); // 'Modified' - objects still referenced

Mixing Data Types Without Proper Validation

When dealing with user input or external data, validate before converting:

Apex

String userInput = '42.7x'; // Invalid number
try {
    Integer value = Integer.valueOf(userInput);
} catch (TypeException e) {
    // Handle gracefully
}

Real-World Application Example

Let me show you how these concepts come together in a realistic scenario. Imagine you’re building a territory management feature that assigns accounts to sales reps based on industry and revenue.

Apex

public class TerritoryAssignmentService {
    
    public static void assignTerritories(List<Account> accounts) {
        // Collect unique industries
        Set<String> industries = new Set<String>();
        for (Account acc : accounts) {
            if (String.isNotBlank(acc.Industry)) {
                industries.add(acc.Industry);
            }
        }
        
        // Query territory rules
        List<TerritoryRule__c> rules = [
            SELECT Industry__c, MinRevenue__c, MaxRevenue__c, AssignedUser__c
            FROM TerritoryRule__c
            WHERE Industry__c IN :industries
        ];
        
        // Build lookup map: Industry -> List of rules
        Map<String, List<TerritoryRule__c>> rulesByIndustry = 
            new Map<String, List<TerritoryRule__c>>();
        
        for (TerritoryRule__c rule : rules) {
            if (!rulesByIndustry.containsKey(rule.Industry__c)) {
                rulesByIndustry.put(rule.Industry__c, new List<TerritoryRule__c>());
            }
            rulesByIndustry.get(rule.Industry__c).add(rule);
        }
        
        // Assign owners based on rules
        List<Account> accountsToUpdate = new List<Account>();
        
        for (Account acc : accounts) {
            List<TerritoryRule__c> applicableRules = 
                rulesByIndustry.get(acc.Industry);
            
            if (applicableRules != null) {
                for (TerritoryRule__c rule : applicableRules) {
                    Decimal revenue = acc.AnnualRevenue != null ? 
                        acc.AnnualRevenue : 0;
                    
                    if (revenue >= rule.MinRevenue__c && 
                        revenue <= rule.MaxRevenue__c) {
                        acc.OwnerId = rule.AssignedUser__c;
                        accountsToUpdate.add(acc);
                        break;
                    }
                }
            }
        }
        
        if (!accountsToUpdate.isEmpty()) {
            update accountsToUpdate;
        }
    }
}

This example demonstrates Set for unique values, Map for efficient lookups, List for bulk operations, null checking, and type-safe operations throughout.


Frequently Asked Questions

What’s the difference between List and Set in Apex?

List maintains ordered elements and allows duplicates, while Set stores unique, unordered elements. Use List when order matters or duplicates are acceptable. Use Set when you need unique values or frequent membership testing, as Set provides faster contains() operations on large datasets.

When should I use Decimal instead of Double for numbers?

Always use Decimal for currency and financial calculations. Decimal provides exact precision without floating-point rounding errors that can occur with Double. Use Double only for scientific calculations where approximate values are acceptable.

How do I convert between different Apex data types?

Use explicit casting with parentheses for narrowing conversions (e.g., (Integer)decimalValue) and the valueOf() methods for string conversions (e.g., Integer.valueOf('42')). Apex handles widening conversions implicitly, like Integer to Long or Decimal.

What’s the maximum size for Apex collections?

Collections are limited by heap size, not a fixed element count. The synchronous heap limit is 6MB for most orgs (12MB for some). In practice, this means you can handle thousands of sObject records, but extreme volumes require batch processing to avoid governor limits.

Can I use custom objects as Map keys in Apex?

Map keys must be primitive types (Integer, String, ID, etc.) or system-defined types. You cannot use custom classes or sObjects as keys. For complex key scenarios, generate a unique string representation as the key.

How does type casting work with sObjects?

You can cast a generic sObject to a specific type like Account or Contact: Account acc = (Account)genericObject. However, casting to the wrong type throws a runtime exception, so verify the object type first if there’s uncertainty.

What are enum types used for in Apex?

Enums define a fixed set of named constants, making code more readable and preventing invalid values. Use enums for status values, configuration options, or any scenario with a predetermined set of choices instead of string constants.


Take Your Apex Skills Further

Understanding Apex data types and collections is foundational, but it’s just the beginning. The real skill comes from applying these concepts efficiently in complex business scenarios while respecting Salesforce’s governor limits.

Start by auditing your existing code. Look for opportunities to replace inefficient loops with Map-based lookups, convert repeated membership checks to Sets, or refactor string constants into enums. Small improvements compound into significant performance gains.

Ready to level up your Salesforce development? Explore our complete guide on Apex triggers and best practices or dive into advanced SOQL techniques for efficient data retrieval.

Add a Comment

Your email address will not be published. Required fields are marked *