DEV Community

Cover image for Dynamic Looping Comes to AWS SAM
Eric D Johnson for AWS

Posted on • Originally published at builder.aws.com

Dynamic Looping Comes to AWS SAM

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

With this directory structure:

services/
├── Users/index.py
├── Orders/index.py
└── Products/index.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

A typical workflow looks like this:

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

You don't need special flags or additional configuration to use language extensions with any of these commands.

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 now in the latest SAM CLI. Update and try it:

pip install --upgrade aws-sam-cli
sam --version
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
innovationsiyu profile image
Siyu

The explanation of how Fn::ForEach collapses nearly identical Lambda definitions into a single loop is a solid improvement for template maintainability. I found the detail on SAM CLI automatically generating Mappings to preserve per function CodeUri paths during build and package especially useful, since that avoids a lot of manual work in multi function repos. Full local workflow support from sam build through sam deploy makes this genuinely usable for daily development. The constraints around locally resolvable collections and the five level nesting limit are practical boundaries worth keeping in mind for production templates.

Collapse
 
rdarrylr profile image
Darryl Ruggles

Nice to see new features going into SAM!!!