CoursesPulumiInputs, Outputs & apply

Inputs, Outputs & apply

The async value model that trips everyone.

Intermediate14 min · lesson 5 of 12

You order a new lock for the server room and the locksmith tells you the key gets cut tomorrow. The number that will be stamped on the blade does not exist yet. You still get work done today, because you write the instructions down: when the key exists, stamp it with the room number, drop the spare in the on-call safe, add a line to the key register. Instructions now, real key later. Pulumi's Output<T> is that instruction slip, and most of the baffling Pulumi errors you will ever hit come from someone treating the slip as though it were the key.

Your program is not the deployment. It runs first, in well under a second, and its only job is to describe what should exist. Once it finishes, the Pulumi engine talks to the cloud and finds out what the cloud decided: the bucket's ARN (Amazon Resource Name, the unique identifier string that AWS, Amazon Web Services, stamps on everything it creates), the load balancer's DNS name (Domain Name System, the naming scheme that turns names into addresses), the random suffix glued onto your bucket name. None of that exists while your code is running. So Pulumi hands you an Output<T>, a wrapper meaning "a value of type T that will exist once this deploys".

The mirror image is Input<T>, and it is what every resource argument accepts. In TypeScript it is roughly the union T | Promise<T> | Output<T>. Hand a resource a plain string, a promise, or another resource's Output, and all three work. Inputs are forgiving. That lopsidedness is why the whole model stays invisible right up until the day it does not: the moment your own code wants to read the value, the forgiveness stops.

The Bug Everyone Writes First

index.ts
import * as aws from "@pulumi/aws";
// S3 is Simple Storage Service, the AWS object store.
const bucket = new aws.s3.BucketV2("audit-logs", {});
// Compiles. Deploys. Completely wrong.
export const badMsg = "ARN is " + bucket.arn;
terminal
pulumi up --yes > /dev/null && pulumi stack output badMsg
output
ARN is Calling [toString] on an [Output<T>] is not supported.
To get the value of an Output<T> as an Output<string> consider either:
1: o.apply(v => `prefix${v}suffix`)
2: pulumi.interpolate `prefix${v}suffix`
See https://www.pulumi.com/docs/concepts/inputs-outputs/ for more details.
This function may throw in a future version of Pulumi.

JavaScript wanted a string, so it asked the object for one, and the Pulumi Node SDK (software development kit, the library your program imports) answered with a paragraph of advice instead of a value. It is trying to be helpful. What it cannot do is refuse, because as far as AWS is concerned that paragraph is a perfectly legal tag, description, or log group name. It deploys into your stack (one live copy of your program, such as dev or prod) without a single complaint. Python is blunter: an f-string over an Output gives you <pulumi.output.Output object at 0x7f8c1d4a3b50> and no hint at all.

Four Ways To Use A Value You Do Not Have Yet

Most of the time you need none of the four. Passing an Output straight into another resource's argument works with no ceremony, because that argument is an Input<T> and the engine unwraps it for you. Reach for a helper only when your own code has to touch the value.

index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.BucketV2("audit-logs", {});
// SQS is Simple Queue Service, the AWS message queue.
const queue = new aws.sqs.Queue("audit-events", {});
// 1. Straight in. No helper, and this is what creates the dependency edge.
new aws.s3.BucketVersioningV2("audit-logs-versioning", {
bucket: bucket.id,
versioningConfiguration: { status: "Enabled" },
});
// 2. An Output inside a string: interpolate (or pulumi.concat).
export const logsUrl = pulumi.interpolate`https://${bucket.bucketDomainName}/`;
// 3. Several Outputs at once: all(). The object form keeps the names readable.
export const wiring = pulumi
.all({ arn: bucket.arn, queueUrl: queue.url })
.apply(({ arn, queueUrl }) => `${arn} -> ${queueUrl}`);
// 4. Real logic on the resolved value: apply().
// An SQS ARN reads arn:aws:sqs:eu-west-1:123456789012:audit-events, so [3] is the region.
// (An S3 ARN has an empty region field, which is a nice way to fool yourself here.)
export const queueRegion = queue.arn.apply(a => a.split(":")[3]);

apply(fn) is the general case. Your function runs once the value resolves, and you get back a new Output wrapping whatever you returned. pulumi.interpolate is a template string that understands Outputs. pulumi.concat glues pieces together. pulumi.all waits for a whole set of them, so you can combine values from resources that know nothing about each other. Python has the same four under different names: Output.format, Output.concat, Output.all and .apply. One rule falls out of all of this. Once a value is an Output, everything you compute from it is an Output too, right up until you export it or feed it into a resource.

The case that bites hardest is a policy document, because it is one string that has to carry several Outputs in exact positions. pulumi.jsonStringify exists for that. It takes an object with Outputs buried anywhere inside it, waits for every one of them, then hands back an Output<string> of valid JSON (JavaScript Object Notation, the plain-text format IAM policies are written in). IAM is Identity and Access Management, the AWS service that decides who is allowed to do what.

index.ts
const appRole = new aws.iam.Role("app-role", {
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: "lambda.amazonaws.com" }),
});
// Least-privilege read access for the audit processor, scoped to one bucket.
const readAudit = pulumi.jsonStringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["s3:GetObject"],
Resource: pulumi.interpolate`${bucket.arn}/*`,
}],
});
const readerPolicy = new aws.iam.RolePolicy("app-reader", {
role: appRole.id,
policy: readAudit,
});
// Python: policy=pulumi.Output.json_dumps({...})

Now the version people write first, and what a preview does with it. JSON.stringify knows nothing about Outputs. It asks the object for its JSON form, gets the same style of apology, and bakes it quietly into the policy. The preview below is trimmed to the one resource worth looking at.

index.ts
const readAudit = JSON.stringify({
Version: "2012-10-17",
Statement: [{ Effect: "Allow", Action: ["s3:GetObject"], Resource: bucket.arn }],
});
terminal
pulumi preview --diff
output
Previewing update (dev):
+ aws:iam/rolePolicy:RolePolicy app-reader create
[urn=urn:pulumi:dev::logs-infra::aws:iam/rolePolicy:RolePolicy::app-reader]
name : "app-reader-9f3c1d0"
policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":\"Calling [toJSON] on an [Output<T>] is not supported.\n\nTo get the value of an Output as a JSON value or JSON string consider either:\n 1: o.apply(v => v.toJSON())\n 2: o.apply(v => JSON.stringify(v))\n\nSee https://www.pulumi.com/docs/concepts/inputs-outputs/ for more details.\nThis function may throw in a future version of Pulumi.\"}]}"
role : output<string>
Resources:
+ 4 to create

Two things to notice. The role has not been created yet, so its name shows as output<string>, the CLI's way of writing "unknown". The policy, meanwhile, is fully known, because JSON.stringify resolved it at program time into a paragraph of SDK advice. Reading property values in a preview diff is the cheapest check you have, and a stringified Output is unmistakable there. Send this policy to AWS and IAM rejects it with MalformedPolicyDocument, complaining that the resource must be in ARN format or "*". That is the lucky outcome. The unlucky one is the same mistake in a field the cloud happily accepts: a tag, a description, an object key prefix. That deploys green and then sits in your account looking like configuration somebody meant.

The fix that gets reached for at 6pm
When a policy is rejected because the ARN came out as a paragraph of SDK advice, the fastest way to make the red text go away is to widen the policy. Resource: "*" is accepted immediately, and it grants that role s3:GetObject on every bucket in the account, including the ones holding backups and access logs. This is a real and common path from "annoying async quirk" to "over-permissioned role nobody remembers creating". If a policy suddenly starts working after someone edited the Resource field, read the diff before you approve it.

Every Output Drags Its Dependencies Along

The value is only half of what an Output holds. The other half is the list of resources it came from, like a delivery note stapled to the instruction slip: this value came from that bucket. When you pass bucket.id into another resource, Pulumi records an edge, meaning the bucket has to exist first. Every ordering decision in a Pulumi deployment is built this way, into a DAG (directed acyclic graph, a one-way map of what must come before what) that you never write by hand. apply keeps those edges, so a value you transformed still remembers where it came from. A literal wrapped with pulumi.output("prod") carries none, because it came from nothing.

index.ts
const logGroup = new aws.cloudwatch.LogGroup("audit-processor-logs", {
name: "/aws/lambda/audit-processor",
retentionInDays: 90,
});
const fn = new aws.lambda.Function("audit-processor", {
name: "audit-processor",
// Pulumi infers this edge on its own: the role must exist before the function.
role: appRole.arn,
runtime: aws.lambda.Runtime.NodeJS20dX,
handler: "index.handler",
code: new pulumi.asset.FileArchive("./app"),
}, {
// Pulumi cannot infer these two: nothing in the arguments above mentions them.
dependsOn: [readerPolicy, logGroup],
});

Ordering is a security property, not a tidiness one. A bucket and its server access logging configuration are separate resources, so there is a window between the bucket existing and logging being switched on. Anything written in that window is missing from the access log forever. Same story for an encryption configuration, a public access block, or the log group a function writes into. Where the ordering is invisible to Pulumi because no argument references the other resource, dependsOn is how you say it out loud. Teardown walks the graph backwards, so a missing edge also turns up as a delete that fails, or as a control that gets removed while the thing it was protecting is still standing.

Preview Runs Blind

During pulumi preview, a resource that does not exist yet has no real outputs. Pulumi marks them unknown, and the CLI (command-line interface, the pulumi command you type) prints them as output<string>. Here is the part that catches people. When an apply's input is unknown, the SDK does not run your callback at all. It checks whether the value is known, sees that it is not, hands back an unknown Output and moves on. The code inside those braces never executes. Unknowns are contagious too, so a single unknown inside a pulumi.all makes the whole combined value unknown.

When your apply callback actually runs
1program runs
every Output is still an empty slip
2resources registered
Pulumi builds the dependency graph
3engine calls the cloud
AWS returns the real ARN or ID
4Output resolves
now your apply callback fires
5downstream resource
receives the real value as an input
During a preview of a resource that does not exist yet, steps 3 to 5 never happen. The value stays unknown, your callback is skipped, and the CLI prints output<string> where the real value would go.
index.ts
// Do not ship this. It is here so you can watch a preview lie to you.
const bucket = new aws.s3.BucketV2("audit-logs", {});
bucket.bucket.apply(name => {
if (name.startsWith("audit-")) {
new aws.s3.BucketPolicy(`${name}-deny-insecure`, {
bucket: name, // inside apply, name is a plain string
policy: JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Deny", Principal: "*", Action: "s3:*",
Resource: [`arn:aws:s3:::${name}`, `arn:aws:s3:::${name}/*`],
Condition: { Bool: { "aws:SecureTransport": "false" } },
}],
}),
});
}
});
export const bucketArn = bucket.arn;
terminal
pulumi preview
output
Previewing update (dev):
Type Name Plan
+ pulumi:pulumi:Stack logs-infra-dev create
+ └─ aws:s3:BucketV2 audit-logs create
Outputs:
+ bucketArn: output<string>
Resources:
+ 2 to create
terminal
pulumi up --yes
output
Updating (dev):
Type Name Status
+ pulumi:pulumi:Stack logs-infra-dev created (14s)
+ ├─ aws:s3:BucketV2 audit-logs created (3s)
+ └─ aws:s3:BucketPolicy audit-logs-a1b2c3d-deny-insecure created (2s)
Outputs:
+ bucketArn: "arn:aws:s3:::audit-logs-a1b2c3d"
Resources:
+ 3 created
Duration: 17s

Two to create, three created. The bucket policy is a good policy, and it still went in behind the reviewer's back. That is the operational problem in one line: change review is a control, and the artifact that control inspects is the preview your CI (continuous integration, the pipeline that runs on every push) posts on the pull request. If the preview cannot see a resource, the sign-off is decoration. Run it the other way round and you have an attack. Someone who can land a small, dull-looking change in your infrastructure repository would love to hide a resource inside an apply, because the plan a human reads will never mention it.

So keep resources out of apply callbacks. Anything built in there is invisible to preview, which means the plan understates what will change, and it can be registered late enough to confuse teardown ordering, which is how you end up with an orphan that pulumi destroy leaves behind. Side effects have the same shape of problem. An HTTP request, a file write or a console.log inside an apply does nothing at all during a preview whose input is unknown, then happens for real during up. Keep security decisions out of apply entirely. If a policy attachment depends on a condition, base that condition on something your program already knows while it runs, such as stack configuration or the stack name, never on a value the cloud will tell you later.

Secrets Ride Along Until You Drop Them

Pulumi tracks a second flag next to the value: whether it is secret. Feed a secret Output into apply or all and the result stays secret, so a value derived from a password is encrypted in state (Pulumi's record of everything it manages for this stack) without you asking for it. That propagation is deliberate and it does real work. It also leaves exactly three ways to lose the marking, and all three are things a person does on purpose.

index.ts
import * as command from "@pulumi/command";
const cfg = new pulumi.Config();
const dbPass = cfg.requireSecret("dbPassword"); // Output<string>, marked secret
// Stays secret: the marking rides through apply, even onto a number.
export const passLen = dbPass.apply(p => p.length);
// Leak 1: inside the callback you are holding plaintext, and this lands in the CI log.
// dbPass.apply(p => { console.log("db password:", p); return p; });
// Leak 2: state stays encrypted, the cloud does not. A resource NAME built from a
// secret is plaintext in the console and in CloudTrail requestParameters.
// new aws.ssm.Parameter("p", { name: pulumi.interpolate`/app/${dbPass}`, type: "String", value: "x" });
// Leak 3: pulumi.unsecret(dbPass) strips the marking on purpose. Grep for it in review.
// A provider that hands back a token without marking it: mark it yourself.
// (Vault is HashiCorp's secret store; this shells out to its CLI.)
const bootstrap = new command.local.Command("issue-token", {
create: "vault write -field=token auth/approle/login role_id=$RID",
environment: { RID: cfg.require("vaultRoleId") },
}, { additionalSecretOutputs: ["stdout"] }); // Python: additional_secret_outputs
terminal
pulumi stack output
output
Current stack outputs (3):
OUTPUT VALUE
bucketArn arn:aws:s3:::audit-logs-a1b2c3d
logsUrl https://audit-logs-a1b2c3d.s3.amazonaws.com/
passLen [secret]

Even the length is withheld, because it came out of an apply over a secret. To read it you have to ask for it by name, and asking decrypts state rather than reading it, which is the difference between a value that leaks by accident and one that leaks because somebody decided to look.

terminal
pulumi stack output passLen --show-secrets
output
24

Two Checks Worth Wiring Into CI

The first check catches a stringified Output that already shipped. Run it against the stack outputs of every environment you own, and against any stack you inherited from somebody else.

terminal
pulumi stack output --json | grep -q 'Calling \[to' \
&& { echo 'FAIL: an Output was stringified into a stack output'; exit 1; } \
|| echo 'OK: no stringified Outputs'
output
OK: no stringified Outputs

The second catches resources built inside an apply. Ask the preview how many changes it plans, keep the number, then compare it against what the update reports when it finishes.

terminal
pulumi preview --json | jq -c '.changeSummary'
output
{"create":2}
terminal
pulumi up --yes --json | jq -c 'select(.summaryEvent).summaryEvent.resourceChanges'
output
{"create":3}

Two planned, three created, and the gap is your signal. Fail the pipeline on that mismatch and spell out the reason in the failure message, because the person who hits it next will be certain the tool is broken. It is not. The program described one thing to the reviewer and built another, and the count is the only place that ever showed up.

Quick check
01A teammate wraps a bucket policy in bucket.bucket.apply(name => { if (name.startsWith("audit-")) new aws.s3.BucketPolicy(...) }). Your CI posts pulumi preview on the pull request, and you approve it. What did that preview actually show you about the policy?
Incorrect — Preview cannot know a brand new bucket's generated name. That value is unknown, so nothing inside the apply was evaluated.
Correct — An unknown input means the SDK skips the callback, so the resource gets registered only during up, after your approval.
Incorrect — There is no previous state for a resource the preview never saw, and so nothing to diff against.
Incorrect — Pulumi permits it and will create the resource on up. That permissiveness is exactly the problem.
02You have bucket.id, an Output<string>, and want to use it as the bucket argument of a new BucketVersioningV2. What is the correct, idiomatic way?
Correct — inputs are forgiving, and handing the Output straight to another resource's argument is exactly what wires the ordering.
Incorrect — resource arguments are Input<T> and accept Outputs directly; apply is only for when your own code reads the value.
Incorrect — calling toString on an Output yields a warning paragraph, not the value.
Incorrect — unsecret strips a secret marking and is unrelated to passing an ordinary Output.
03A developer built an IAM (Identity and Access Management) policy with Resource: bucket.arn using JSON.stringify, AWS rejected it with MalformedPolicyDocument, and to make it pass someone changed Resource to the wildcard *. What actually happened?
Incorrect — the error came from a stringified Output baked into the policy, not a legitimate need for *.
Incorrect — the ARN is not a secret; it needed an Output-aware serializer, not unsecret.
Correct — the right fix is pulumi.jsonStringify, which waits for the Output; * is how an async quirk becomes an over-permissioned role.
Incorrect — least privilege scopes to the one bucket ARN, so * is far broader than needed.

Try this

Run pulumi up --yes > /dev/null && pulumi stack output badMsg on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the fix that gets reached for at 6pm. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related