An AWS CDK L2 construct does not expose a CloudFormation property you need to set. How do you set it anyway, and what do you give up by doing so?
answer
- reach through to the L1 resource
- node.defaultChild, then cast
- raw CloudFormation names, not camelCase
- applied after the construct renders
- nothing type-checks the patch
basics
~20 sReach the underlying L1 resource through the construct's node.defaultChild, cast it to its Cfn type, and call addPropertyOverride or addOverride with the raw CloudFormation property path. The override is applied at synth time and the CDK does not type-check or validate it.
solid answer
~40 sThis is what escape hatches are for. Every high-level construct wraps a low-level `Cfn*` resource you can reach with `construct.node.defaultChild`, cast to the concrete type, and then mutate: `addPropertyOverride('Path.To.Property', value)` sets something under `Properties`, `addOverride('UpdateReplacePolicy', 'Retain')` reaches sections outside `Properties`, and `addDeletionOverride` / `addPropertyDeletionOverride` remove what the construct generated. The paths and names are **raw CloudFormation** — PascalCase, dotted, indexable — not the construct's camelCase API. What you give up is safety: overrides are applied after the construct renders, so nothing type-checks them, a typo becomes a silently ignored property, and the construct's other behaviour (IAM grants, computed defaults) still assumes what it originally emitted. Always verify with `cdk synth` or `cdk diff` that the override landed, and leave a comment explaining why it exists.
code
typescript · 15 linesimport * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
export function makeBucket(scope: Construct): s3.Bucket {
const bucket = new s3.Bucket(scope, 'Data');
// Reach the underlying L1 and patch the rendered template fragment.
const cfnBucket = bucket.node.defaultChild as s3.CfnBucket;
// Raw CloudFormation names, applied at synth time, not type-checked.
cfnBucket.addPropertyOverride('AccelerateConfiguration.AccelerationStatus', 'Enabled');
cfnBucket.addOverride('UpdateReplacePolicy', 'Retain');
return bucket;
}go deeper
Know that the CDK always lets you reach the raw CloudFormation underneath a construct, so a missing property is never a dead end — and that you check the synthesized template afterwards.
Name the mechanism precisely: node.defaultChild cast to the Cfn type, then addPropertyOverride or addOverride using raw CloudFormation paths, plus the two deletion variants for removing generated content.
Weigh the cost. Explain that overrides are unvalidated patches applied after rendering, that the construct's generated IAM and companion resources do not adapt, and when instantiating the L1 directly is the cleaner call.
Own the policy: where escape hatches are acceptable, how they are documented and retired, and when a recurring workaround should become an Aspect, an internal construct library, or an upstream contribution.
## The ladder, in order Before reaching for an escape hatch, exhaust the cheaper options in this order: 1. **A property or method on the construct you already have.** Constructs frequently expose behaviour through `addX()` methods rather than constructor props, and the gap is often in your knowledge rather than the library. 2. **Instantiating the L1 `Cfn*` resource directly.** If the high-level construct is fighting you across many properties, dropping to the one-to-one CloudFormation mapping for that resource can be cleaner than a pile of overrides, and it stays fully typed. 3. **An escape hatch on the L1 inside the L2** — the subject of this question. 4. **A raw `CfnResource`** for a resource type that has no generated L1 at all, where you supply `type` and `properties` yourself. 5. **A custom resource** when the thing you need is an API call CloudFormation does not model. Escaping should be a deliberate step down that ladder, not the first move. ## Reaching the L1 A high-level construct is a scope containing other constructs, and the one it primarily represents is registered as its *default child*. `construct.node.defaultChild` returns it, typed loosely, so you cast: ```typescript const bucket = new s3.Bucket(this, 'Data'); const cfnBucket = bucket.node.defaultChild as s3.CfnBucket; cfnBucket.addPropertyOverride('AccelerateConfiguration.AccelerationStatus', 'Enabled'); ``` Not every construct has a single default child — a pattern that creates several peer resources may not — in which case you locate the child you want through `node.findChild(id)` or by walking `node.children`, using the logical IDs visible in the synthesized template. ## The four override methods On any `CfnResource`: - `addPropertyOverride(path, value)` — sets a value under the resource's `Properties`. The path is dotted and can index into lists. - `addOverride(path, value)` — sets anything on the resource's template fragment, including sections outside `Properties` such as `UpdateReplacePolicy`, `DeletionPolicy`, `Metadata` or `Condition`. - `addPropertyDeletionOverride(path)` — removes a property the construct emitted. - `addDeletionOverride(path)` — removes any node from the fragment. Deletion overrides matter more than people expect: sometimes the problem is not a missing property but an opinionated default the construct inserted that the service now rejects. ## Why the names are raw Overrides are applied *after* the construct has rendered itself into template JSON, as a patch on that JSON. That is why the paths use CloudFormation's own spelling, and why nothing validates them: at that point the CDK has no schema to check the patch against. A misspelled path does not raise an error — it inserts a property CloudFormation has never heard of, and you learn about it when the deployment fails, or worse, when the setting you thought you applied is simply absent. The habit that removes this risk is cheap: run `cdk synth` and read the emitted resource, or `cdk diff` and confirm the property appears where you expect. Treat an unverified escape hatch as untested code. ## What else you give up **The construct's internal reasoning is now stale.** A high-level construct computes IAM grants, log groups and companion resources from the props you passed it. Change the underlying resource behind its back and those derivations do not follow. Flip an encryption or networking property by override and the grants the construct generated may no longer be sufficient — or may reference a key it does not know about. **Upgrades get more interesting.** Overrides survive CDK upgrades because they are applied last, which is mostly good. But when the library later adds a real prop for the same thing, you can end up with the supported prop and your override fighting, with the override winning and hiding a now-supported code path. Comment every override with what it works around and, where relevant, the tracking issue, so someone can delete it later. **Reviewability drops.** `addPropertyOverride('Something.Deep.0.Value', x)` conveys far less to a reviewer than a named prop. If you find several overrides on one construct, that is a signal to instantiate the L1 directly instead. ## Applying overrides broadly When the same override must apply across many constructs — a tag, a removal policy, a required property on every bucket in the app — an **Aspect** visits every construct in a scope and can apply the change in one place. That keeps the escape hatch to a single, reviewable location rather than sprinkling casts through the codebase, and it is the pattern platform teams reach for when enforcing an organizational rule.
- How do you verify that an escape hatch actually did what you intended?Run `cdk synth` and read the emitted resource, or `cdk diff` and check the property appears with the value you expect. Overrides are unvalidated string paths, so a typo produces no error at all — it quietly inserts a property nothing will honour. Reading the synthesized template is the only real proof, and it takes seconds.
- When would you instantiate the L1 Cfn resource directly instead of overriding an L2?When you are overriding several properties on the same construct, or when the construct's generated extras are actively in your way. At that point the L2 buys you nothing but indirection, while the L1 gives you full, typed control over exactly what is emitted. The cost is writing the IAM policies and companion resources the L2 was creating for you.
- You need the same override applied to every bucket in a large CDK app. What is the cleaner mechanism?An Aspect. It visits every construct in a scope at synth time, so you match the ones you care about and apply the change in a single reviewable place instead of scattering casts through the codebase. That is the usual pattern when a platform team enforces an organization-wide rule such as a removal policy or a required encryption setting.
- What happens to an escape hatch when a later CDK version adds a proper prop for the same setting?The override keeps winning, because it is applied after the construct renders. That is fine mechanically but bad for maintenance: the supported prop looks like it works and does not, and the workaround outlives its reason. This is why an override deserves a comment naming what it works around, so someone can retire it deliberately.
saying these in an interview costs you the question
- Uses the L2's camelCase property names in an override path
- Assumes a mistyped override path raises an error
- Believes an override calls the AWS API to patch a deployed resource
- Thinks the construct's IAM grants adapt to an overridden property
- Reaches for overrides before checking the construct's own methods