VOOZH about

URL: https://dev.to/aws/dynamic-looping-comes-to-aws-sam-2k0l

⇱ Dynamic Looping Comes to AWS SAM - DEV Community


Update (May 22, 2026): Since the initial launch, local processing of Language Extensions has moved behind an opt-in flag for compatibility. You now enable it with --language-extensions, an environment variable, or a samconfig.toml entry. Details in the "Enabling local processing" section below.

AWS SAM CLI, the command-line tool for building and deploying serverless applications, now supports AWS CloudFormation Language Extensions. The one I am most excited about is Fn::ForEach, which brings dynamic looping to your YAML templates, but it's close. If you, like me, have been copy-pasting resource definitions to infinity, that stops today.

ForEach is the star, but it ships alongside Length, ToJsonString, FindInMap with default values, and conditional deletion policies. All of them work across your full local SAM workflow: build, invoke, validate, package, deploy, and sync.

In this post, I walk through what CloudFormation Language Extensions brings to SAM CLI, show you how each extension works, and demonstrate the full local development experience.

The problem: template duplication

To show why this matters, take a look at the following example. I have three AWS Lambda functions, Lambda being the serverless compute service, that each handle a different endpoint on the same API. But, almost everything about them is the same. They have the same runtime, the same memory configuration, and nearly the same structure. The only differences are the name, handler, and possibly some environment variables.

The template looks like this:

Resources:
 UsersFunction:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: users.handler
 CodeUri: ./src
 MemorySize: 256
 Environment:
 Variables:
 FUNCTION_NAME: Users

 OrdersFunction:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: orders.handler
 CodeUri: ./src
 MemorySize: 256
 Environment:
 Variables:
 FUNCTION_NAME: Orders

 ProductsFunction:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: products.handler
 CodeUri: ./src
 MemorySize: 256
 Environment:
 Variables:
 FUNCTION_NAME: Products

Three resources, nearly identical, and if I need to change the memory size or add a tracing configuration, I'm making the same edit three times. The template is fragile and hard to maintain, and it only gets worse at ten or twenty functions. So what can I do about it? That's where Language Extensions come in.

What are CloudFormation Language Extensions?

CloudFormation Language Extensions is a transform (AWS::LanguageExtensions) that unlocks a suite of extended intrinsic functions for your CloudFormation templates. These functions have existed in CloudFormation for a while. What's new is that SAM CLI now processes them locally for your entire development workflow, meaning you can build, invoke, and test locally before deploying.

The full suite includes:

Extension What it does
Fn::ForEach Iterate over a collection and generate resources for each item
Fn::Length Return the length of an array
Fn::ToJsonString Convert an object or array to a JSON string
Fn::FindInMap with DefaultValue Look up a value in a Mappings section with a fallback when the key doesn't exist
Conditional DeletionPolicy Use Fn::If in DeletionPolicy (e.g., Retain in prod, Delete in dev)
Conditional UpdateReplacePolicy Use Fn::If in UpdateReplacePolicy

To enable them, I add AWS::LanguageExtensions to my template's Transform section alongside the SAM transform:

Transform:
 - AWS::LanguageExtensions
 - AWS::Serverless-2016-10-31

With that in place, I can start using Fn::ForEach to solve the duplication problem I showed earlier.

Fn::ForEach: define once, generate many

Take a look at the same three functions rewritten with Fn::ForEach. Instead of repeating the definition three times, I define it once and let the loop generate the rest:

Transform:
 - AWS::LanguageExtensions
 - AWS::Serverless-2016-10-31

Resources:
 Fn::ForEach::Functions:
 - Name
 - [Users, Orders, Products]
 - ${Name}Function:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: !Sub "${Name}.handler"
 CodeUri: ./src
 MemorySize: 256
 Environment:
 Variables:
 FUNCTION_NAME: !Sub ${Name}

That single definition generates three functions: UsersFunction, OrdersFunction, and ProductsFunction. If I need to add a fourth, I add one item to the collection array. If I need to change the memory size, I change it in one place.

The anatomy of Fn::ForEach breaks down into four parts:

  • Loop name: Fn::ForEach::Functions, a unique identifier for this loop
  • Iterator variable: Name, the variable that takes each value in turn
  • Collection: [Users, Orders, Products], the values to iterate over
  • Template body: The resource definition using ${Name} for substitution

That covers the basic case where all functions share the same source code. However, what happens when each function needs its own code directory?

Per-function code directories

In many projects, each function lives in its own folder. Fn::ForEach handles this through dynamic artifact properties, where the CodeUri itself uses the loop variable:

Resources:
 Fn::ForEach::Services:
 - Name
 - [Users, Orders, Products]
 - ${Name}Service:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: index.handler
 CodeUri: ./services/${Name}

With this directory structure:

services/
├── Users/index.py
├── Orders/index.py
└── Products/index.py

SAM CLI builds each function from its own directory and generates Mappings sections automatically to preserve the Fn::ForEach structure in the deployed template. To see this in action, I check .aws-sam/build/template.yaml after a build:

Mappings:
 SAMCodeUriServices:
 Users:
 CodeUri: UsersService
 Orders:
 CodeUri: OrdersService
 Products:
 CodeUri: ProductsService

Resources:
 Fn::ForEach::Services:
 - Name
 - [Users, Orders, Products]
 - ${Name}Service:
 Type: AWS::Serverless::Function
 Properties:
 CodeUri:
 Fn::FindInMap:
 - SAMCodeUriServices
 - Ref: Name
 - CodeUri
 Handler: index.handler

SAM CLI generates the SAMCodeUriServices mapping so that each collection value resolves to its own build artifact. At package time, those paths become Amazon S3 URIs. I don't need to manage any of this.

The same pattern works for API endpoints. Let me show one more example before moving on to the other extensions.

API endpoints from a loop

I can generate multiple API endpoints from a single definition by attaching an Amazon API Gateway event source inside the loop:

Resources:
 Fn::ForEach::Endpoints:
 - Endpoint
 - [users, products, orders]
 - ${Endpoint}Function:
 Type: AWS::Serverless::Function
 Properties:
 Runtime: python3.11
 Handler: index.handler
 CodeUri: ./endpoints/${Endpoint}
 Events:
 Api:
 Type: Api
 Properties:
 Path: !Sub /${Endpoint}
 Method: get

I run sam local start-api, and I get three working endpoints: /users, /products, /orders, all generated from that single resource definition.

Fn::ForEach is the biggest addition, but the other extensions in the suite solve real problems of their own.

Beyond Fn::ForEach: Length, ToJsonString, FindInMap, and more

Each of the remaining extensions addresses a specific gap in what CloudFormation templates could express before.

Fn::Length

When I generate resources from a collection, I sometimes need to know how many items are in that collection. Maybe I'm setting a concurrency limit based on the number of services, or creating an Amazon CloudWatch alarm that scales with the fleet. Previously, I'd hardcode that number and forget to update it when the collection changed. Fn::Length returns the length of an array at deploy time:

Parameters:
 ServiceNames:
 Type: CommaDelimitedList
 Default: "api,worker,scheduler"

Resources:
 ServiceCountMetric:
 Type: AWS::CloudWatch::Alarm
 Properties:
 AlarmDescription: !Sub "Monitoring${Fn::Length(ServiceNames)}services"

Fn::ToJsonString

Lambda functions frequently need structured configuration passed as environment variables. The problem is that environment variables are strings, so I end up building JSON by hand inside !Sub with escaped quotes and line breaks, and it breaks the moment someone forgets a backslash.

Fn::ToJsonString solves this by converting an object to a JSON string inline:

Environment:
 Variables:
 CONFIG:
 Fn::ToJsonString:
 region: !Ref AWS::Region
 table: !Ref MyTable
 version: "2.0"

No more escaping quotes in YAML, and no more !Sub gymnastics to build JSON strings. I define the object naturally and let Fn::ToJsonString handle serialization. The function reads CONFIG as a standard JSON string at runtime, and if I add a field, I add it to the YAML object and the serialization stays correct.

Fn::FindInMap with DefaultValue

Mappings are great for region-specific or environment-specific configuration. However, Fn::FindInMap throws a hard error if the key doesn't exist. So if I add a new region or deploy to one I didn't explicitly map, the stack fails. I end up maintaining an exhaustive list of every possible key, or wrapping lookups in conditions.

Now I can provide a default value that CloudFormation uses when the key isn't found:

Mappings:
 RegionConfig:
 us-east-1:
 BucketPrefix: "use1"
 eu-west-1:
 BucketPrefix: "euw1"

Resources:
 MyBucket:
 Type: AWS::S3::Bucket
 Properties:
 BucketName: !Sub
 - "${Prefix}-my-app"
 - Prefix:
 Fn::FindInMap:
 - RegionConfig
 - !Ref AWS::Region
 - BucketPrefix
 - DefaultValue: "default"

If I deploy to ap-southeast-1, no crash. I get "default" instead of a stack failure.

Conditional DeletionPolicy and UpdateReplacePolicy

In a multi-environment setup, I want production Amazon DynamoDB tables and S3 buckets to survive accidental stack deletions. But in dev, I want clean teardowns without orphaned resources cluttering the account. Previously, I needed separate templates or manual post-deploy steps because DeletionPolicy only accepted a static string.

Now it accepts intrinsic functions:

Conditions:
 IsProd: !Equals [!Ref Environment, prod]

Resources:
 MyTable:
 Type: AWS::DynamoDB::Table
 DeletionPolicy: !If [IsProd, Retain, Delete]
 UpdateReplacePolicy: !If [IsProd, Retain, Delete]
 Properties:
 TableName: !Sub "${Environment}-data"
 BillingMode: PAY_PER_REQUEST

One template handles both: production retains data on deletion, dev cleans up after itself.

That covers all the extensions. The next question is how they fit into the SAM CLI workflow.

Full SAM CLI workflow support

Every SAM CLI command supports Language Extensions:

  • sam build: Expands loops in memory, builds each generated function
  • sam local invoke: Invoke expanded functions by name
  • sam local start-api: Serves all generated API endpoints
  • sam validate: Catches syntax errors and unsupported patterns locally
  • sam package: Preserves the Fn::ForEach structure with S3 URIs
  • sam deploy: Uploads your original template for CloudFormation to process
  • sam sync: Syncs changes to the cloud, including code-only updates

SAM CLI expands language extensions in memory for local operations because it needs to know which functions to build and invoke. But your original unexpanded template is what goes to CloudFormation. You get the full local development experience with no template modification for deployment.

Enabling local processing

Local processing of language extensions is opt-in. You enable it with the --language-extensions flag:

sam build --language-extensions
sam local invoke UsersFunction --language-extensions --event events/get-user.json
sam local start-api --language-extensions
# Test your endpoints at http://localhost:3000/users
sam deploy --language-extensions --guided

Each command needs its own activation. To avoid repeating the flag, set the environment variable:

export SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1
sam build
sam local invoke UsersFunction

Or persist it in your samconfig.toml:

[default.build.parameters]
language_extensions = true

If multiple methods are set, the precedence order is: CLI flag > samconfig.toml > environment variable. Before you get started, there are a few constraints worth knowing about.

Limitations and constraints

Collections must be locally resolvable. Your Fn::ForEach collection can be a static list ([A, B, C]) or a parameter reference (!Ref MyParam). It cannot use Fn::GetAtt, Fn::ImportValue, or SSM/Secrets Manager dynamic references. These require cloud API calls that SAM CLI can't make locally. The error messages are clear and suggest workarounds.

Maximum 5 levels of nesting. You can nest Fn::ForEach loops (environments x services, for example), but CloudFormation caps it at 5 levels deep. You probably won't hit this in practice.

Collection values are fixed at package time. If you use a parameter-based collection with dynamic CodeUri, the parameter values you use at sam package must match what you use at sam deploy. SAM CLI warns you when this applies.

With those constraints in mind, getting started is straightforward.

Get started

This feature is available in SAM CLI v1.160.0 and later. Update and try it:

pip install --upgrade aws-sam-cli
sam --version

Take one of your templates with duplicated resources, add the AWS::LanguageExtensions transform, and replace the copy-paste with Fn::ForEach. If you don't have the latest CLI, the install guide has you covered.

This has been one of the most requested features in SAM CLI history (#5647 had years of community upvotes), and the implementation covers the full command surface. Dynamic looping in YAML, supported end-to-end. Define your resources once, generate as many as you need, and deploy with the same workflow you already know.

If you run into issues or want to see what's next, the SAM CLI repo is where it all happens.