A Field Set is a grouping of fields defined on an object in Salesforce Setup. Instead of hard-coding which fields appear in a page or query, you reference the Field Set by name, and an administrator can add, remove, or reorder fields without a developer touching code. That separation of concerns is the entire value proposition.
Imagine you ship a Visualforce page or an LWC component that displays ten account fields. Three months later, the business wants two more fields shown and one removed. Without Field Sets, that is a code change, a deployment, and a release cycle. With Field Sets, an admin opens Setup, edits the Field Set, and the page reflects the change immediately — zero code touched.
Account).Account_Summary).
The API name you chose (Account_Summary) is how you reference the Field Set in both Apex and Visualforce. It is case-sensitive in Apex.
The Schema namespace exposes Field Sets through the SObjectType describe result. The key classes are Schema.FieldSet and Schema.FieldSetMember.
public class FieldSetDemo {
/**
* Returns the list of FieldSetMember objects for a given
* object API name and Field Set name.
*/
public static List<Schema.FieldSetMember> getMembers(
String objectApiName,
String fieldSetName) {
Schema.SObjectType targetType =
Schema.getGlobalDescribe().get(objectApiName);
if (targetType == null) {
throw new IllegalArgumentException(
'Unknown object: ' + objectApiName);
}
Schema.DescribeSObjectResult describeResult =
targetType.getDescribe();
Map<String, Schema.FieldSet> fieldSets =
describeResult.fieldSets.getMap();
if (!fieldSets.containsKey(fieldSetName)) {
throw new IllegalArgumentException(
'Unknown Field Set: ' + fieldSetName +
' on ' + objectApiName);
}
return fieldSets.get(fieldSetName).getFields();
}
}
Schema.FieldSetMember exposes several important properties:
getFieldPath() — the API field name (e.g., Name, AnnualRevenue). For span fields, this can be a dotted path like Account.Name.getLabel() — the display label you see in the UI.getType() — returns a Schema.DisplayType enum value (e.g., STRING, CURRENCY).getRequired() — true if the field is marked required in the Field Set definition.getDBRequired() — true if the underlying field is required at the database level.isRequired() — convenience check: true if either getRequired() or getDBRequired() is true.// Print each member's info to debug log
List<Schema.FieldSetMember> members =
FieldSetDemo.getMembers('Account', 'Account_Summary');
for (Schema.FieldSetMember m : members) {
System.debug(
'Field: ' + m.getFieldPath() +
' | Label: ' + m.getLabel() +
' | Type: ' + m.getType() +
' | Req: ' + m.isRequired()
);
}
The most common Apex pattern is constructing a SOQL query at runtime using the Field Set members. This ensures the query always fetches exactly the fields the admin has configured.
public class AccountFieldSetQuery {
public static List<SObject> queryWithFieldSet(
String fieldSetName,
List<Id> recordIds) {
// 1. Gather field API names from the Field Set
List<Schema.FieldSetMember> members =
FieldSetDemo.getMembers('Account', fieldSetName);
Set<String> fieldNames = new Set<String>{'Id'}; // Id is always needed
for (Schema.FieldSetMember m : members) {
fieldNames.add(m.getFieldPath());
}
// 2. Build the SELECT clause
String selectClause = String.join(
new List<String>(fieldNames), ', ');
// 3. Bind Ids safely — never concatenate user-supplied values
String soql =
'SELECT ' + selectClause +
' FROM Account' +
' WHERE Id IN :recordIds';
// 4. Execute and return
return Database.query(soql);
}
}
Note the deliberate use of a bind variable (:recordIds) rather than string concatenation for the WHERE clause. Field names from a Field Set are admin-controlled metadata, not user input, so they are safe to interpolate into the SELECT clause. User-supplied values must always be bound.
List<Id> ids = new List<Id>{
'001000000000001AAA',
'001000000000002AAA'
};
List<SObject> results =
AccountFieldSetQuery.queryWithFieldSet('Account_Summary', ids);
for (SObject rec : results) {
System.debug(rec);
}
Visualforce has first-class support for Field Sets via the apex:repeat component and the $ObjectType global variable. No Apex controller code is needed for simple read-only rendering.
<!-- AccountFieldSetPage.page -->
<apex:page standardController="Account">
<apex:pageBlock title="Account Summary">
<apex:pageBlockSection columns="2">
<apex:repeat
value="{!$ObjectType.Account.FieldSets.Account_Summary}"
var="f">
<apex:outputField value="{!Account[f]}" />
</apex:repeat>
</apex:pageBlockSection>
</apex:pageBlock>
</apex:page>
{!Account[f]} uses the dynamic binding syntax. The f variable is a FieldSetMember, and Visualforce knows how to resolve it against the record. apex:outputField automatically renders the correct input type and respects field-level security.
<apex:page standardController="Account" sidebar="false">
<apex:form>
<apex:pageBlock title="Edit Account" mode="edit">
<apex:pageBlockSection columns="1">
<apex:repeat
value="{!$ObjectType.Account.FieldSets.Account_Summary}"
var="f">
<apex:inputField
value="{!Account[f]}"
required="{!OR(f.DBRequired, f.Required)}" />
</apex:repeat>
</apex:pageBlockSection>
<apex:pageBlockButtons>
<apex:commandButton value="Save" action="{!save}" />
<apex:commandButton value="Cancel" action="{!cancel}" />
</apex:pageBlockButtons>
</apex:pageBlock>
</apex:form>
</apex:page>
LWC does not have native Field Set support the way Visualforce does. You must wire up an Apex method that reads the Field Set and return the field metadata to the component. Then use lightning-record-view-form or lightning-record-edit-form with lightning-output-field / lightning-input-field.
public with sharing class FieldSetController {
public class FieldSetInfo {
@AuraEnabled public String fieldPath;
@AuraEnabled public String label;
@AuraEnabled public Boolean required;
}
@AuraEnabled(cacheable=true)
public static List<FieldSetInfo> getFieldSetFields(
String objectApiName,
String fieldSetName) {
List<Schema.FieldSetMember> members =
FieldSetDemo.getMembers(objectApiName, fieldSetName);
List<FieldSetInfo> result = new List<FieldSetInfo>();
for (Schema.FieldSetMember m : members) {
FieldSetInfo info = new FieldSetInfo();
info.fieldPath = m.getFieldPath();
info.label = m.getLabel();
info.required = m.isRequired();
result.add(info);
}
return result;
}
}
// fieldSetViewer.js
import { LightningElement, api, wire } from 'lwc';
import getFieldSetFields from '@salesforce/apex/FieldSetController.getFieldSetFields';
export default class FieldSetViewer extends LightningElement {
@api recordId;
@api objectApiName;
@api fieldSetName = 'Account_Summary';
fields = [];
error;
@wire(getFieldSetFields, {
objectApiName: '$objectApiName',
fieldSetName: '$fieldSetName'
})
wiredFields({ data, error }) {
if (data) {
this.fields = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.fields = [];
}
}
}
<!-- fieldSetViewer.html -->
<template>
<lightning-card title="Record Details">
<div class="slds-p-around_medium">
<template lwc:if={fields.length}>
<lightning-record-view-form
record-id={recordId}
object-api-name={objectApiName}>
<template for:each={fields} for:item="f">
<lightning-output-field
key={f.fieldPath}
field-name={f.fieldPath}>
</lightning-output-field>
</template>
</lightning-record-view-form>
</template>
<template lwc:if={error}>
<p class="slds-text-color_error">
Failed to load fields.
</p>
</template>
</div>
</lightning-card>
</template>
<!-- fieldSetViewer.js-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__RecordPage">
<property name="fieldSetName" type="String"
label="Field Set Name"
default="Account_Summary" />
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
Exposing fieldSetName as a design property means admins can configure which Field Set the component uses directly from the Lightning App Builder — no code change required.
Schema.FieldSet and Schema.FieldSetMember are real metadata objects that exist in any org. You do not need to mock them. Your test simply calls the method and asserts the result is a non-empty list. The Field Set must exist in the test org (including scratch orgs with the source deployed).
@isTest
private class FieldSetDemoTest {
@isTest
static void getMembers_returnsNonEmptyList() {
// Requires 'Account_Summary' Field Set deployed in the test org
Test.startTest();
List<Schema.FieldSetMember> members =
FieldSetDemo.getMembers('Account', 'Account_Summary');
Test.stopTest();
System.assertNotEquals(
0,
members.size(),
'Field Set should contain at least one field'
);
}
@isTest
static void getMembers_throwsOnUnknownObject() {
try {
FieldSetDemo.getMembers('FakeObject__c', 'SomeFieldSet');
System.assert(false, 'Expected exception not thrown');
} catch (IllegalArgumentException e) {
System.assert(
e.getMessage().contains('Unknown object'),
'Wrong exception message: ' + e.getMessage()
);
}
}
@isTest
static void getMembers_throwsOnUnknownFieldSet() {
try {
FieldSetDemo.getMembers('Account', 'NonExistent_FS');
System.assert(false, 'Expected exception not thrown');
} catch (IllegalArgumentException e) {
System.assert(
e.getMessage().contains('Unknown Field Set'),
'Wrong exception message: ' + e.getMessage()
);
}
}
}
Field Sets are a first-class packaging construct. When you include a Field Set in a managed package, subscribers can extend it by adding their own custom fields to the "Available" section. This is the recommended extensibility pattern for ISVs — you ship a default set of fields, and customers tailor it without forking your component code.
package.xml under the FieldSet metadata type.YourNS__Account_Summary.<!-- package.xml snippet -->
<types>
<members>Account.Account_Summary</members>
<name>FieldSet</name>
</types>
Schema.FieldSetMember respects FLS at query time when used in Visualforce's dynamic binding. In Apex, call stripInaccessible() or check isAccessible() yourself before exposing data.Owner.Name are valid in a Field Set but require a subquery or alias in SOQL. Parse the field path and handle the dot case explicitly.Schema.getGlobalDescribe() for a non-existent Field Set will throw at runtime, not compile time.getGlobalDescribe() — Calling getGlobalDescribe() in a tight loop is expensive. Cache the result in a static variable within the same transaction.getFields() from managed package code.public class DescribeCache {
private static final Map<String, Schema.SObjectType> GLOBAL_DESCRIBE =
Schema.getGlobalDescribe();
public static Schema.DescribeSObjectResult getSObjectDescribe(
String objectApiName) {
Schema.SObjectType t = GLOBAL_DESCRIBE.get(objectApiName);
if (t == null) {
throw new IllegalArgumentException(
'Unknown object: ' + objectApiName);
}
return t.getDescribe();
}
}
By storing Schema.getGlobalDescribe() in a static final field, you pay the describe cost once per transaction no matter how many methods call it.
Use Field Sets whenever field selection needs to be configurable by admins at the object level without a code deployment. They are the right tool for any page or component where the displayed fields may change over the lifetime of the application — intake forms, summary views, search result tables, or managed package UI. The core pattern is always the same: read Schema.FieldSetMember objects via describeResult.fieldSets.getMap(), extract the getFieldPath() values, and build your SOQL or drive your template from that list. Remember to cache global describe results, handle the namespace prefix in packaged code, and always strip or check field-level security when returning data from an Apex @AuraEnabled method.