YAML has been the configuration language of the cloud-native era by default, not by design. It is whitespace-sensitive in ways that cause real production incidents. It has no type system. It provides no mechanism for reuse, short of anchors and aliases that quickly become unreadable. It silently coerces values, the notorious NO becoming false, 1.0 becoming a float, and on becoming true, in ways that require memorization rather than reasoning.
Engineering teams have tolerated these limitations because the alternatives were worse, or at least less familiar. HCL is powerful but Terraform-specific. CUE is expressive but has a steep learning curve. Dhall is principled but alien to most developers. Jsonnet is underused and under-tooled.
Then Apple published Pkl (pronounced "pickle"), an open-source configuration language designed explicitly for the problems YAML fails to solve, type safety, reuse, abstraction, and validation built into the language rather than bolted on afterward.
After one month of using Pkl across real infrastructure configuration, Kubernetes manifests, application configs, and CI/CD templates, here is an honest account of what it does well, where it still struggles, and whether it is ready to replace YAML in a production environment.
What Is Pkl?
Pkl (Package Konfig Language) is a configuration language developed by Apple and open-sourced in February 2024. It compiles to JSON, YAML, XML, or plist, meaning it integrates with any toolchain that reads those formats, without requiring the entire ecosystem to adopt Pkl natively.
Its core design goals:
- Type safety - every value has a type, enforced at evaluation time
- Reuse through templates - configurations extend and override base templates
- Validation built in - constraints are expressed in the language, not in external schema validators
- Readability - syntax designed to be familiar to developers without YAML's whitespace traps
- Evaluation, not just declaration - expressions, conditionals, and computed values are first-class
The compiler outputs are standard formats, so adopting Pkl is a source change, not an output change. Your Kubernetes cluster still receives YAML. Your application still reads JSON. Pkl lives in your repository and your CI pipeline, not in your runtime.
Syntax First: What Pkl Actually Looks Like
The fastest way to understand Pkl is to see it alongside the YAML it replaces.
Basic Value Declaration
# YAML
app:
name: orders-service
port: 3000
debug: false
replicas: 3
# Pkl
app {
name = "orders-service"
port = 3000
debug = false
replicas = 3
}
The structural difference is immediately visible: Pkl uses braces instead of indentation, = for assignment instead of :, and quoted strings that are unambiguously strings, "false" is a string, false is a boolean. There is no YAML parser needed to figure out which is which.
Type Annotations
// Pkl - explicit types on every field
class AppConfig {
name: String
port: Int(this > 1024 && this < 65535) // inline constraint
debug: Boolean
replicas: Int(this >= 1 && this <= 10)
environment: "staging" | "production" | "development" // union type
}
app: AppConfig = new {
name = "orders-service"
port = 3000
debug = false
replicas = 3
environment = "production"
}
Setting replicas = 0 against this class fails at evaluation time with a clear error message, not at runtime when Kubernetes tries to create zero pods. Setting environment = "prod" (a typo) fails immediately because the value is not in the union type. This is the type safety YAML schemas attempt to provide through external validators, Pkl builds it directly into the language.
YAML Coercion Problems - Solved
# YAML - notorious coercion traps
enabled: yes # becomes true
version: 1.0 # becomes float in some parsers
country: NO # becomes false (Norway's ISO code!)
port: 8080 # integer
tag: "8080" # string — same value, different type, different behavior
# Pkl - unambiguous
enabled: Boolean = true
version: String = "1.0"
country: String = "NO"
port: Int = 8080
tag: String = "8080"
Every value's type is explicit. The Norway NO incident, a real class of YAML bug that has affected Kubernetes configurations in production, cannot happen in Pkl.
Templates and Inheritance: The Killer Feature
YAML's anchor-and-alias system for reuse looks like this:
# YAML anchors - functional but unreadable at scale
.defaults: &defaults
imagePullPolicy: IfNotPresent
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
deployments:
orders-service:
<<: *defaults
replicas: 3
users-service:
<<: *defaults
replicas: 2
resources:
limits:
memory: "1Gi" # override — but does this merge or replace?
The << merge key behavior is ambiguous in some parsers and was officially deprecated in YAML 1.2. Pkl's approach is unambiguous:
// Pkl - templates with explicit inheritance
class DeploymentDefaults {
imagePullPolicy: String = "IfNotPresent"
memoryRequest: String = "256Mi"
cpuRequest: String = "100m"
memoryLimit: String = "512Mi"
cpuLimit: String = "500m"
}
class Deployment extends DeploymentDefaults {
name: String
replicas: Int = 1
}
ordersService: Deployment = new {
name = "orders-service"
replicas = 3
// inherits all defaults
}
usersService: Deployment = new {
name = "users-service"
replicas = 2
memoryLimit = "1Gi" // explicitly override one field, all others inherited
}
The inheritance is explicit, the override is unambiguous, and the IDE can show you exactly which fields are inherited versus overridden, something YAML anchors fundamentally cannot do.
Real-World Usage: Kubernetes Manifests
Here is a production-representative Pkl template for a Kubernetes Deployment, showing how templating applies to the configuration I write most often:
// k8s/templates/deployment.pkl
module deployment
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.0#/api/apps/v1/Deployment.pkl"
class AppDeployment {
name: String
namespace: String = "default"
image: String
tag: String
replicas: Int(this >= 1) = 1
port: Int = 8080
cpuRequest: String = "100m"
memoryRequest: String = "256Mi"
cpuLimit: String = "500m"
memoryLimit: String = "512Mi"
env: Listing<EnvVar> = new {}
}
class EnvVar {
name: String
value: String
}
function toK8sDeployment(config: AppDeployment): Deployment = new {
metadata {
name = config.name
namespace = config.namespace
labels { app = config.name }
}
spec {
replicas = config.replicas
selector { matchLabels { app = config.name } }
template {
metadata { labels { app = config.name } }
spec {
containers {
new {
name = config.name
image = "\(config.image):\(config.tag)"
ports { new { containerPort = config.port } }
resources {
requests {
cpu = config.cpuRequest
memory = config.memoryRequest
}
limits {
cpu = config.cpuLimit
memory = config.memoryLimit
}
}
envFrom = config.env
}
}
}
}
}
}
// k8s/services/orders-service.pkl
import "../templates/deployment.pkl"
output {
value = deployment.toK8sDeployment(new deployment.AppDeployment {
name = "orders-service"
namespace = "production"
image = "your-org/orders-service"
tag = "2.4.1"
replicas = 3
env {
new { name = "DATABASE_URL"; value = "postgresql://db:5432/orders" }
new { name = "NODE_ENV"; value = "production" }
}
})
}
Compile to YAML for Kubernetes:
pkl eval k8s/services/orders-service.pkl -f yaml > k8s/manifests/orders-service.yaml
The manifest is generated, not handwritten. The template enforces the constraint that replicas is at least 1. The image and tag are separate fields, no string concatenation errors. The output is standard Kubernetes YAML that kubectl apply consumes without modification.
CI/CD Integration
Pkl integrates into CI/CD pipelines anywhere you currently generate or validate YAML:
# .github/workflows/pkl-generate.yml
name: Generate and Validate Configs
on: [push, pull_request]
jobs:
pkl-generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Pkl CLI
run: |
curl -L https://github.com/apple/pkl/releases/latest/download/pkl-linux-amd64 \
-o /usr/local/bin/pkl
chmod +x /usr/local/bin/pkl
- name: Validate all Pkl configurations
run: pkl eval --multiple-file-output /dev/null pkl/**/*.pkl
- name: Generate Kubernetes manifests
run: |
for file in k8s/services/*.pkl; do
service=$(basename "$file" .pkl)
pkl eval "$file" -f yaml > "k8s/manifests/${service}.yaml"
done
- name: Commit generated manifests
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add k8s/manifests/
git diff --cached --quiet || git commit -m "chore: regenerate manifests from Pkl"
git push
Honest Assessment After One Month
What Pkl Gets Right
Type safety is genuinely useful. Finding configuration errors at compile time rather than at kubectl apply time, or worse, at runtime, is a real quality improvement. Inline constraints like Int(this > 0) replace entire schema validation workflows.
Templates and inheritance are the best solution I've seen. The YAML anchor problem is real. Pkl's class-based inheritance with explicit overrides is significantly cleaner than any YAML reuse mechanism.
String interpolation without escaping traps. "\(config.image):\(config.tag)" is readable and unambiguous. YAML multiline string handling is notoriously confusing.
Evaluation-time computation. Generating environment-specific configuration from a single template with conditional expressions reduces the number of files to maintain.
Where Pkl Still Struggles
Ecosystem maturity is early. The Kubernetes package library (pkl-k8s) covers most resources but not all. For anything not in the package, you're writing raw mappings to target types, which works but loses the ergonomic advantage.
IDE support is improving but incomplete. The VS Code extension provides syntax highlighting and basic completion. IntelliJ support is more mature. Neither yet matches the experience of working in a fully-supported language.
Team adoption friction. Pkl is not YAML. Developers familiar with YAML need to learn new syntax, new mental models for inheritance, and a new compilation step. This is a real cost that requires team buy-in.
Debugging compilation errors. When a constraint fails or a type mismatch occurs in a deeply nested template, the error messages are sometimes less precise than you'd want. This is improving with each release.
Not every toolchain accepts Pkl natively. Tools that consume configuration at runtime, some CI/CD platforms, older infrastructure tools, can't read Pkl directly. The compilation step to YAML/JSON is necessary, which adds pipeline complexity.
Should You Adopt Pkl?
Adopt Pkl today if:
- You maintain large, complex YAML configurations with significant duplication
- Type errors and coercion bugs have caused real incidents in your environment
- Your team writes configuration as code and values IDE support
- You're already compiling or generating YAML (Helm charts, Kustomize overlays), Pkl replaces the templating layer cleanly
Wait on Pkl if:
- Your YAML configs are small and simple, the overhead isn't justified
- Your team doesn't have bandwidth for adoption friction right now
- You need native tool support without a compilation step
- You're evaluating for a production migration within the next quarter, the ecosystem is maturing fast, but not yet complete
Conclusion
After one month, Pkl has earned a permanent place in my infrastructure toolchain, but not as a wholesale YAML replacement, yet. It is the right tool for complex, template-heavy configuration that benefits from type safety and reuse. It is not yet the obvious choice for simple, stable configuration that existing YAML handles without friction.
The trajectory is clear, Pkl is solving real problems that YAML ignores and doing so with a thoughtfulness that most configuration languages lack. The ecosystem will catch up. The IDE tooling will improve. Team familiarity will build.
YAML isn't dead. But for the first time in years, there's a credible successor that makes the case on engineering merit rather than novelty.
Have you tried Pkl in your infrastructure? What was your first-month experience, similar frustrations, different use cases? Share in the comments.
Top comments (0)