CDKTF vs Pulumi for Python GIS Teams

A GIS engineering team that already writes Python — GeoPandas for analysis, Rasterio for imagery, Shapely for geometry — reasonably asks why its infrastructure should be written in a second language. Two tools answer that question differently: CDK for Terraform synthesizes HCL-equivalent JSON from Python and hands it to the Terraform engine, while Pulumi executes Python directly against its own engine and state. The distinction sounds academic and is not: it determines what you can compute at plan time, how a failure is reported, and what the rest of your estate must look like. This comparison extends Terraform vs Pulumi for GIS into the specific case where Python is already the team’s working language, and sits within Spatial IaC Architecture and Fundamentals.

The architectural difference that actually matters

CDKTF is a synthesizer. Your Python runs once, produces a JSON description of the desired infrastructure, and exits; the Terraform binary then plans and applies that JSON. Pulumi is a runtime. Your Python runs during the deployment itself, and resource outputs arrive as Output values that resolve asynchronously as the engine creates things.

For a GIS team the consequence is concrete. Under CDKTF, any Python you write executes before the plan, so you may freely compute a tile pyramid’s zoom range, derive bucket prefixes from a coordinate grid, or read a projection definition from a file — all of it becomes literal values in the synthesized JSON. What you cannot do is branch on something the cloud will only tell you during apply, because by then your Python has already finished. Under Pulumi the reverse holds: you can react to a resource’s actual identifier as it is created, but any value that comes from a resource is an Output that must be composed with apply() rather than used as a plain Python value, and forgetting this produces the single most common Pulumi bug — a resource name that contains the string <pulumi.output.Output object at 0x...>.

Where Python runs in CDKTF compared with Pulumi In the CDK for Terraform path, the Python program runs to completion first and emits a JSON description; the Terraform engine then plans that JSON and applies it. All Python computation therefore happens strictly before the plan and cannot depend on anything the cloud reports during apply. In the Pulumi path, the Python program runs as part of the deployment: the engine creates resources while the program is still executing, so resource identifiers come back as asynchronous Output values that must be composed rather than read directly. CDK for Terraform — synthesize, then plan Python runs JSON emitted terraform plan apply Python is done Pulumi — the program is the deployment Python runs and creates engine returns Outputs Outputs resolve asynchronously — compose with apply(), never read directly. All CDKTF computation precedes the plan; all Pulumi resource values arrive during it.

Choosing between them for a spatial estate

Four questions decide it in practice, and only the first is about Python at all.

Does the rest of your estate already run Terraform? If it does, CDKTF keeps one engine, one state format, one policy language and one set of drift tooling, and adds a Python authoring layer on top. Pulumi introduces a second engine with its own state, which is a real operational cost — two backends to secure, two lock mechanisms, two sets of policy rules. The tool-selection reasoning in Terraform vs Pulumi Decision Matrix for GIS Teams applies directly, and the answer for most established estates is that engine plurality is not free.

Is the geospatial logic a build-time computation or a deploy-time reaction? Deriving 512 bucket prefixes from a quadkey scheme, expanding a list of UTM zones into per-zone processing queues, or reading a .prj file to decide a partition layout are all build-time — CDKTF handles them perfectly, and the synthesized JSON is auditable afterwards. Reacting to the actual endpoint a database was assigned, or looping over resources whose count depends on a value the provider returns, is deploy-time and is where Pulumi’s model is genuinely easier.

How important is a reviewable plan artifact? CDKTF’s synthesized JSON is a diffable file that a reviewer can read, and the Terraform plan against it is the familiar format every policy tool already consumes. Pulumi’s preview is excellent for humans but is not a Terraform plan, so any policy tooling built around plan JSON needs a parallel implementation.

Who maintains it in two years? CDKTF has a smaller community and its provider bindings are generated, so an obscure provider’s Python surface may be thin. Pulumi’s Python support is first-class and consistent across providers. Against that, a CDKTF estate degrades gracefully — the synthesized JSON is ordinary Terraform and can be adopted directly if the Python layer is abandoned, which is a genuine exit that Pulumi does not offer as cheaply.

Choosing between CDKTF and Pulumi for a Python GIS team Start by asking whether the estate already runs Terraform. If it does, ask whether the geospatial logic is computed before the plan or must react to values the cloud returns during deployment. Build-time computation with an existing Terraform estate points to CDK for Terraform, which keeps one engine and one state format. Deploy-time reaction, or a greenfield estate with no Terraform commitment, points to Pulumi. A separate consideration overrides both: if policy tooling is built around Terraform plan JSON, that requirement favours CDK for Terraform because Pulumi's preview is not a Terraform plan. Estate already runs Terraform? yes no — greenfield Logic computed before the plan? yes CDK for Terraform no Pulumi Overriding constraint policy tooling reads Terraform plan JSON → favours CDKTF

The same stack, written both ways

The example is a raster bucket whose prefixes are derived from a UTM zone list — exactly the kind of build-time geospatial computation that motivates writing infrastructure in Python at all.

# CDKTF: the loop runs during synth, so the JSON contains 60 literal prefixes.
from cdktf import App, TerraformStack, S3Backend
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_aws.s3_object import S3Object
from constructs import Construct


class RasterStack(TerraformStack):
    def __init__(self, scope: Construct, ident: str, zones: list[int]):
        super().__init__(scope, ident)
        AwsProvider(self, "aws", region="eu-west-1")
        # Remote state with locking — the same backend the rest of the estate uses.
        S3Backend(self, bucket="gis-tfstate", key="raster/terraform.tfstate",
                  dynamodb_table="gis-tfstate-locks", encrypt=True)

        bucket = S3Bucket(self, "raster", bucket="gis-raster-archive")

        # Plain Python: this executes at synth time, before any plan exists.
        for zone in zones:
            hemisphere = "north" if zone > 0 else "south"
            S3Object(self, f"prefix-{hemisphere}-{abs(zone)}",
                     bucket=bucket.id,
                     key=f"utm/{hemisphere}/{abs(zone):02d}/",
                     content="")


app = App()
RasterStack(app, "raster", zones=list(range(1, 61)))
app.synth()
# Pulumi: the same shape, but bucket.id is an Output resolved during deploy.
import pulumi
import pulumi_aws as aws

zones = list(range(1, 61))

bucket = aws.s3.Bucket("raster", bucket="gis-raster-archive")

for zone in zones:
    aws.s3.BucketObject(
        f"prefix-north-{zone:02d}",
        # bucket.id is an Output. Passing it to a resource argument is fine —
        # the engine resolves it. Interpolating it into a string is not, and
        # that mistake is the classic Pulumi bug.
        bucket=bucket.id,
        key=f"utm/north/{zone:02d}/",
        content="",
    )

# Deriving a value FROM an output requires apply(), because the value does not
# exist yet when this line runs.
pulumi.export("archive_root", bucket.id.apply(lambda b: f"s3://{b}/utm/"))

Both snippets pin their provider through the project manifest — cdktf.json for the first, Pulumi.yaml and the Python requirements for the second — and neither should be run without that pinning, for the reasons set out in Versioning and Publishing Private GIS Terraform Modules.

Operational consequences to weigh

State. CDKTF writes Terraform state to whatever backend you configure, so your existing State Backend Selection decision carries over unchanged, locking included. Pulumi keeps its own state in its service or a self-managed backend, and adopting it means designing and securing a second state system.

Policy. Rules written against Terraform plan JSON — including everything in Policy as Code for Spatial Resources — apply to CDKTF output unchanged, because the plan is a Terraform plan. Pulumi requires its own policy packs, which are capable but are a second implementation of the same rules, and two implementations drift.

Debugging. A CDKTF failure has two possible layers: a Python error during synth, which is an ordinary stack trace, or a Terraform error during apply, which refers to the synthesized JSON rather than to your Python and therefore reads at one remove from the code you wrote. Pulumi failures always refer to your program, which is a genuine ergonomic advantage.

Testing. CDKTF synthesizes to JSON, which is trivial to assert over in pytest without provisioning anything — a natural fit for the plan-contract layer described in Testing and Validation for Spatial IaC. Pulumi offers mocked unit tests that intercept resource registrations, which is more powerful but requires learning a testing model specific to the tool.

Operational comparison of CDKTF and Pulumi A four-row comparison. State: CDK for Terraform reuses the existing Terraform backend and locking, while Pulumi introduces a second state system to secure. Policy: CDK for Terraform is covered by existing plan-JSON rules, while Pulumi needs a parallel policy implementation that can drift from the first. Failures: CDK for Terraform reports apply errors against synthesized JSON rather than the Python source, while Pulumi reports directly against the program. Testing: CDK for Terraform synthesizes JSON that ordinary pytest can assert over, while Pulumi provides a mocked resource-registration model specific to the tool. Dimension CDK for Terraform Pulumi State existing backend and locking a second state system Policy existing plan-JSON rules a parallel implementation Failures reported against the JSON reported against the program Testing pytest over synthesized JSON mocked resource registration

Frequently Asked Questions

Can I use GeoPandas or Shapely inside the infrastructure program?

Under CDKTF, yes and it is a good fit: the library runs during synth, and whatever geometry you compute becomes literal values in the JSON. Under Pulumi you can also import them, but keep the computation off the Output path — geometry derived from a resource value must be produced inside an apply() callback, which runs during deployment and makes the program slower and harder to reason about.

Does CDKTF let me keep using existing Terraform modules?

Yes. CDKTF can consume Terraform modules directly, generating Python bindings for their inputs and outputs, which means a team can adopt Python authoring without rewriting a module library it already trusts. This is often the deciding factor for an estate with substantial existing module investment.

Which is easier to migrate away from later?

CDKTF, by a clear margin. Its output is ordinary Terraform JSON, so abandoning the Python layer means adopting the synthesized configuration and continuing with Terraform. Leaving Pulumi means exporting state and importing resources into another tool, which is the substantially harder path described in Migrating a GeoServer Stack from Terraform to Pulumi run in reverse.

Is it reasonable to run both in one estate?

It is reasonable when the boundary is explicit and narrow — for example Pulumi for one dynamically shaped processing stack and Terraform or CDKTF for everything else, with the two joined by a documented reference rather than by shared state. The pattern and its costs are set out in Integrating Terraform and Pulumi in One GIS Pipeline. What is not reasonable is letting the boundary emerge by accident.