In an AWS CloudFormation template, why would you use Fn::Sub rather than Fn::Join, and how do you include a literal ${...} in the string that CloudFormation must not substitute?
answer
- one function writes the string as-is
- the other concatenates a list
- account, region and partition come free
- a bang after the brace changes everything
- IAM policy variables must survive untouched
basics
~10 sFn::Sub interpolates ${...} placeholders directly inside a string, which is far more readable than assembling fragments with Fn::Join. A literal dollar-brace is escaped by writing ${!Literal}, which renders as ${Literal} untouched.
solid answer
~40 s`Fn::Join` builds a string by concatenating a list of fragments, so an ARN turns into an unreadable array of literals and functions. `Fn::Sub` lets you write the string as it will look and mark the holes: `!Sub 'arn:${AWS::Partition}:s3:::${DataBucket}/*'`. Inside the string, `${Name}` resolves like `Ref` — a parameter value, a resource's default value, or a pseudo parameter such as `AWS::Region`, `AWS::AccountId`, `AWS::StackName` or `AWS::Partition` — and `${Logical.Attribute}` resolves like `Fn::GetAtt`. There is also a two-element form, `!Sub [template, {Key: value}]`, where the map supplies extra variables computed from other functions. To emit a literal dollar-brace you escape with a bang: `${!aws:username}` renders as `${aws:username}`, which is exactly what you need when embedding an IAM policy variable or a shell expansion inside a substituted string.
code
yaml · 22 linesAWSTemplateFormatVersion: '2010-09-09'
Parameters:
AmiId:
Type: AWS::EC2::Image::Id
Resources:
DataBucket:
Type: AWS::S3::Bucket
Web:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref AmiId
InstanceType: t3.micro
UserData:
Fn::Base64: !Sub |
#!/bin/bash
echo "region=${AWS::Region}" > /etc/app.env
echo "bucket=${DataBucket}" >> /etc/app.env
echo "bucket_arn=${DataBucket.Arn}" >> /etc/app.env
echo "running as ${!USER} in ${!HOME}" >> /etc/app.env
Outputs:
ObjectArnPattern:
Value: !Sub 'arn:${AWS::Partition}:s3:::${DataBucket}/*'go deeper
Know that Fn::Sub fills ${...} placeholders inside a string and that Fn::Join glues a list together, and be able to name AWS::Region and AWS::AccountId as values you get for free.
Explain that ${Logical.Attr} inside Fn::Sub behaves like Fn::GetAtt, show the two-element form with a variable map, and demonstrate the ${!Literal} escape for a shell script or IAM policy variable.
Show judgment about constructed versus read ARNs, and why the partition and URL-suffix pseudo parameters are what keep a template portable across commercial, China and GovCloud regions.
Own the convention across a template estate: where strings may be constructed at all, how naming prefixes derive from AWS::StackName, and what a hardcoded region or partition costs when the estate expands.
## The problem Fn::Sub solves Most strings in a template are part literal and part unknown: an ARN, a bucket path, a bootstrap script, a tag value that should contain the stack name. Before `Fn::Sub` existed the only tool was `Fn::Join`, which concatenates a list: ```yaml Resource: Fn::Join: - '' - - 'arn:aws:s3:::' - !Ref DataBucket - '/*' ``` That is three lines of punctuation to express one string, and it hardcodes the `aws` partition. `Fn::Sub` writes the same thing as the thing itself: ```yaml Resource: !Sub 'arn:${AWS::Partition}:s3:::${DataBucket}/*' ``` ## What can appear inside the braces Within the substituted string, three kinds of name resolve: 1. **A parameter name** — resolves to the value supplied at deploy time, exactly like `Ref`. 2. **A resource logical ID** — resolves to that resource's `Ref` value. 3. **A logical ID with a dotted attribute**, `${DataBucket.Arn}` — resolves like `Fn::GetAtt`. Pseudo parameters are the fourth family and are just `Ref`-able names AWS provides: - `AWS::Region` — the region the stack is deploying into. - `AWS::AccountId` — the 12-digit account ID. - `AWS::Partition` — `aws`, `aws-cn` or `aws-us-gov`. Hardcoding `aws` is what breaks a template the day it is deployed in China or GovCloud. - `AWS::StackName` and `AWS::StackId` — useful for name prefixes and tags. - `AWS::URLSuffix` — `amazonaws.com` in commercial regions, `amazonaws.com.cn` in China. - `AWS::NoValue` — special: not a string but the marker that removes a property, and only meaningful inside `Fn::If`. Note what cannot appear: you cannot nest arbitrary functions inside the string itself. `${Fn::ImportValue ...}` is not a thing. If you need a computed value, use the second form. ## The two-element form ```yaml Value: !Sub - 'https://${Domain}/${Stage}/health' - Domain: !ImportValue shared-net-Domain Stage: !Ref StageName ``` The first element is the template string, the second a map of extra variable names to values, and those values may themselves be intrinsic functions. Parameters, resources and pseudo parameters still resolve without being listed in the map. ## Escaping: the ${!Literal} form Two very common payloads legitimately contain `${...}` that CloudFormation must leave alone: - **Shell scripts** in `UserData`, where `${HOME}` or `${1}` belongs to bash. - **IAM policy variables** in an inline policy document, such as `${aws:username}` or `${aws:PrincipalTag/team}`, which IAM expands at authorization time. Insert a bang after the opening brace and CloudFormation emits the rest verbatim: ```yaml Resource: !Sub 'arn:${AWS::Partition}:s3:::${DataBucket}/home/${!aws:username}/*' ``` That deploys as `.../home/${aws:username}/*`, so IAM — not CloudFormation — does the substitution. Forgetting the bang produces one of two failures: either a deployment error complaining that `aws:username` is not a valid parameter or resource, or, worse, a policy that silently resolves to something meaningless. ## Practical guidance Prefer `Fn::Sub` for anything with more than one moving part; it reads like the output and it diffs cleanly in review, which matters because a template is code other people read. Keep `Fn::Join` for genuinely list-shaped work — joining a variable-length list produced by another function, for instance, where there is no fixed string to write. Build ARNs with `${AWS::Partition}`, `${AWS::AccountId}` and `${AWS::Region}` rather than typing them, and prefer reading an ARN off the resource with `${Logical.Arn}` over constructing it at all when the resource is in the same template — a constructed ARN encodes an assumption about the service's ARN format, and a read attribute does not. Finally, remember that `Fn::Sub` produces a string. If a property wants a list, `Fn::Sub` alone will not give you one; you need a list-valued parameter type, `Fn::Split`, or a list of individually substituted strings.
- Why should an ARN in a template use ${AWS::Partition} rather than a literal aws?Because the partition differs outside the commercial regions — `aws-cn` in China and `aws-us-gov` in GovCloud. A hardcoded `arn:aws:` ARN is silently wrong there: the policy or property references an identifier that cannot exist, so access fails or the stack rejects the value. `AWS::URLSuffix` plays the same role for hostnames.
- When is Fn::Join still the better choice?When the list is genuinely dynamic — joining the output of `Fn::GetAZs`, a `CommaDelimitedList` parameter, or a list built by another function — because there is no fixed template string to write. Fn::Sub interpolates into a literal string and cannot iterate over a list.
- Can you use Fn::ImportValue directly inside an Fn::Sub string?No — the braces accept only parameter names, resource logical IDs, dotted attributes and pseudo parameters. Put the import in the second form's variable map instead: `!Sub ['${Subnet}', {Subnet: !ImportValue net-SubnetId}]`. The same applies to any other function you need evaluated.
saying these in an interview costs you the question
- Claiming Fn::Sub can call any intrinsic function inside the braces
- Hardcoding arn:aws: instead of using AWS::Partition
- Forgetting the bang so an IAM policy variable gets substituted away
- Thinking Fn::Sub returns a list when the property needs one
- Believing AWS::NoValue is an ordinary string-valued pseudo parameter