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.

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

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 endadd(index, element)– Insert at specific positionget(index)– Retrieve by positionremove(index)– Delete by positionclear()– Empty the entire listsize()– Get element countisEmpty()– Check if emptysort()– Order elements
Set Methods
add(element)– Add unique elementcontains(element)– Check membershipremove(element)– Delete elementaddAll(collection)– Add multiple elementsretainAll(collection)– Keep only matching elements
Map Methods
put(key, value)– Add or update entryget(key)– Retrieve value by keycontainsKey(key)– Check if key existskeySet()– Get all keys as Setvalues()– Get all values as Listremove(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 stringordinal()– 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.