Introduction
Configuration management is one of those deceptively simple problems that grows teeth as systems scale. A small YAML file for a single service seems harmless enough. But multiply that by hundreds of microservices, each with environment-specific overrides, conditional logic, and validation requirements, and you end up with a sprawling configuration landscape riddled with subtle bugs, inconsistencies, and maintenance nightmares. JSON does not support comments. YAML has footgun semantics around truthy values and indentation. TOML struggles with deeply nested structures. None of them offer type safety, validation, or code reuse.
Enter Pkl (pronounced "pickle"), a configuration language developed by Apple and open-sourced in February 2024. Pkl is not just another serialization format—it is a full-fledged programming language designed specifically for generating configuration. It combines the readability of YAML, the expressiveness of a programming language, and the safety of a type system. With Pkl, you define configuration schemas with types, compose configurations through inheritance and mixins, validate values with constraints, and generate output in JSON, YAML, PLIST, or any custom format.
This guide explores Pkl's core concepts, its type system and validation capabilities, practical implementation patterns for real infrastructure, and how it compares to existing configuration tools like Jsonnet, CUE, and Dhall.
Understanding Pkl: Core Concepts
The Design Philosophy
Pkl was born out of Apple's internal need to manage configuration at scale. Apple's infrastructure involves thousands of services, each requiring configuration that varies across environments, regions, and deployment stages. Traditional formats like JSON and YAML proved inadequate because they lack mechanisms for code reuse, validation, and abstraction.
Pkl's design philosophy centers on three principles: typed configuration means every value has a type, and the type system catches errors at evaluation time rather than at runtime. Composable configuration means you can build configurations from reusable building blocks through classes, mixins, and templates. Multi-format output means Pkl generates the format your tooling expects—JSON for APIs, YAML for Kubernetes, PLIST for macOS services.
Classes and Objects
Pkl's class system is its most powerful abstraction. A class defines a typed template for configuration values, complete with default values, constraints, and documentation:
class Server {
hostname: String
port: Int(1..65535)
protocol: "http"|"https"|"grpc" = "https"
maxConnections: Int(1..) = 1000
function isSecure(): Boolean = protocol != "http"
}
myServer = new Server {
hostname = "api.example.com"
port = 443
}Notice several things: the port field uses an Int(1..65535) constraint that rejects values outside that range. The protocol field uses a union type that restricts values to three specific strings. The maxConnections field has a default value. The isSecure() function encapsulates logic. These features are impossible in YAML or JSON.
Amending and Extending
One of Pkl's most distinctive features is the amend operator (!). It lets you take an existing configuration and modify specific fields without rewriting the rest:
baseServer = new Server {
hostname = "default.example.com"
port = 8080
}
productionServer = (baseServer) {
port = 443
maxConnections = 10000
}
stagingServer = (baseServer) {
hostname = "staging.example.com"
maxConnections = 100
}This pattern eliminates the copy-paste anti-pattern that plagues JSON and YAML configurations. When you change a default in baseServer, all derived configurations automatically inherit the change.
Modules and Imports
Pkl supports a module system that enables code reuse across projects. You can import classes, functions, and values from other Pkl files, external packages, or HTTP resources:
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/Kubernetes.pkl"
amends "Kubernetes.pkl"
metadata {
name = "my-app"
namespace = "production"
labels { "app" = "my-app" }
}
spec {
replicas = 3
template {
spec {
containers {
new {
name = "app"
image = "my-app:v2.1.0"
ports { new { containerPort = 8080 } }
}
}
}
}
}This imports a Kubernetes schema package that provides types and validation for Kubernetes resources, letting you write K8s manifests with full type checking.
Architecture and Design Patterns
The Evaluation Pipeline
Pkl's evaluation pipeline transforms .pkl files into output formats through several stages. First, the parser reads the source text and produces an Abstract Syntax Tree. The evaluator walks the AST, resolving imports, evaluating expressions, and enforcing constraints. The renderer converts the evaluated Pkl objects into the target format—JSON, YAML, PLIST, or XML.
This architecture means Pkl files are never directly consumed by applications. Instead, your CI/CD pipeline evaluates Pkl files and produces configuration artifacts in the format your tooling expects. This separation of concerns means you get the full power of Pkl during development and authoring, while your runtime sees plain JSON or YAML.
Schema-First Configuration
A powerful pattern is to define your configuration schemas as Pkl classes in a shared package, then import those schemas in your service configurations. This approach centralizes the configuration contract in one place—if you need to add a new required field or tighten a validation constraint, you update the schema package and all consumers are automatically validated against the new rules during their next build.
// config-schema.pkl — shared schema package
class ServiceConfig {
name: String(!= "")
version: String.matches("\\d+\\.\\d+\\.\\d+")
environment: "development"|"staging"|"production"
database: Database
cache: Cache
logging: Logging
}
class Database {
host: String(!= "")
port: Int(1..65535) = 5432
name: String(!= "")
poolSize: Int(1..100) = 10
ssl: Boolean = true
}
class Cache {
host: String(!= "")
port: Int(1..65535) = 6379
ttlSeconds: Int(0..) = 300
}
class Logging {
level: "debug"|"info"|"warn"|"error" = "info"
format: "json"|"text" = "json"
}Multi-Environment Configuration
Pkl excels at managing multi-environment configurations through a layered approach:
// environments/base.pkl
amends "config-schema.pkl"
name = "order-service"
version = "2.4.1"
database {
host = "localhost"
name = "orders_dev"
poolSize = 5
}
cache {
host = "localhost"
ttlSeconds = 60
}
// environments/production.pkl
amends "base.pkl"
environment = "production"
database {
host = "db.prod.internal"
name = "orders_prod"
poolSize = 50
ssl = true
}
cache {
host = "cache.prod.internal"
ttlSeconds = 3600
}
logging {
level = "warn"
}Production only overrides the fields that differ from the base. If you add a new field with a sensible default to base.pkl, it automatically propagates to all environments.
Step-by-Step Implementation
Installing Pkl and Setting Up a Project
Pkl provides command-line tools for evaluation, a REPL for interactive exploration, and language server integration for IDEs:
# Install Pkl on macOS
brew install pkl
# Install on Linux
curl -fsSL https://pkl-lang.org/install.sh | sh
# Initialize a new Pkl project
mkdir my-config && cd my-config
pkl project init
# This creates PklProject and PklProject.deps.jsonDefining Configuration Classes
Create typed configuration classes with validation constraints:
// AppConfig.pkl
class AppConfig {
/// The application name, used for service discovery and logging
name: String(!= "")
/// Semantic version string
version: String.matches("\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?")
/// Deployment environment
environment: "development"|"staging"|"production"
/// HTTP server configuration
http: HttpConfig = new { }
/// Database connection settings
database: DatabaseConfig
/// Feature flags
features: Mapping<String, Boolean> = Mapping()
}
class HttpConfig {
host: String = "0.0.0.0"
port: Int(1..65535) = 8080
requestTimeoutSeconds: Int(1..300) = 30
cors: CorsConfig = new { }
}
class CorsConfig {
allowedOrigins: List<String> = List("*")
allowedMethods: List<String> = List("GET", "POST", "PUT", "DELETE")
maxAgeSeconds: Int(0..) = 3600
}
class DatabaseConfig {
host: String(!= "")
port: Int(1..65535) = 5432
name: String(!= "")
username: String(!= "")
password: String(!= "") @pkl.Sensitive
poolSize: Int(1..100) = 10
sslMode: "disable"|"require"|"verify-ca"|"verify-full" = "require"
}Generating JSON Output
Evaluate Pkl files and generate configuration in your target format:
# Output as JSON
pkl eval config.pkl --format json -o config.json
# Output as YAML
pkl eval config.pkl --format yaml -o config.yaml
# Output specific fields using expression evaluation
pkl eval -e 'database.host' config.pkl
# Validate without producing output
pkl eval --project . config.pkl > /dev/nullIntegrating with Node.js
Use the Pkl CLI from Node.js to generate configuration during build or startup:
import { execSync } from 'child_process';
import fs from 'fs';
interface AppConfig {
name: string;
version: string;
environment: string;
http: { host: string; port: number };
database: { host: string; port: number; name: string; poolSize: number };
}
function loadConfig(configPath: string): AppConfig {
const result = execSync(`pkl eval ${configPath} --format json`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return JSON.parse(result);
}
// Load environment-specific configuration
const env = process.env.NODE_ENV || 'development';
const config = loadConfig(`config/environments/${env}.pkl`);
console.log(`Starting ${config.name} v${config.version} in ${config.environment}`);
console.log(`Server: ${config.http.host}:${config.http.port}`);
console.log(`Database: ${config.database.host}:${config.database.port}/${config.database.name}`);Kubernetes Deployment with Pkl
Generate Kubernetes manifests using Pkl's k8s package:
// k8s/deployment.pkl
import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.0.1#/Kubernetes.pkl"
amends "Kubernetes.pkl"
metadata {
name = "order-service"
namespace = "production"
labels {
"app" = "order-service"
"version" = "2.4.1"
"managed-by" = "pkl"
}
}
spec {
replicas = 3
selector {
matchLabels { "app" = "order-service" }
}
template {
metadata {
labels { "app" = "order-service" }
}
spec {
containers {
new {
name = "order-service"
image = "registry.example.com/order-service:2.4.1"
ports { new { containerPort = 8080; name = "http" } }
resources {
requests { cpu = "250m"; memory = "512Mi" }
limits { cpu = "1000m"; memory = "1Gi" }
}
env {
new { name = "NODE_ENV"; value = "production" }
new {
name = "DB_PASSWORD"
valueFrom { secretKeyRef { name = "order-db-creds"; key = "password" } }
}
}
readinessProbe {
httpGet { path = "/healthz"; port = 8080 }
initialDelaySeconds = 10
periodSeconds = 5
}
}
}
}
}
}Real-World Use Cases and Case Studies
Use Case 1: Microservice Configuration at Scale
A platform team managing 200 microservices defines shared configuration schemas as a Pkl package. Each service imports the shared schema and provides its specific values. When the platform team needs to enforce TLS 1.3 across all services, they update the DatabaseConfig class's sslMode default in one place. The next CI run for every service validates its configuration against the updated schema, and any service that explicitly sets an insecure mode gets flagged. This approach replaced a fragile system of YAML templates with Jinja2 that had become unmaintainable.
Use Case 2: Multi-Cloud Infrastructure Definitions
A company operating across AWS, GCP, and Azure uses Pkl to define cloud infrastructure configurations that are vendor-specific yet share common values. A base configuration defines shared settings like naming conventions, tagging policies, and monitoring thresholds. Cloud-specific modules extend the base with provider-specific resource definitions. Pkl generates Terraform variable files, CloudFormation parameter files, and ARM template inputs from a single source of truth.
Use Case 3: CI/CD Pipeline Configuration
A DevOps team replaces their Jenkinsfile and GitHub Actions YAML with Pkl. Shared build pipeline classes define common stages (lint, test, build, deploy) with typed parameters. Each repository's pipeline.pkl extends the shared pipeline and customizes only the relevant stages. Pkl generates the platform-specific YAML (GitHub Actions .yml or Jenkins Jenkinsfile) during the CI setup phase. This eliminates the copy-paste of identical YAML blocks across 150 repositories.
Best Practices for Production
-
Version your schema packages: Use semantic versioning for your Pkl schema packages. Breaking changes to configuration schemas should bump the major version, allowing consumers to upgrade on their own schedule.
-
Use
@pkl.Sensitivefor secrets: Mark fields containing passwords, API keys, or tokens with the@pkl.Sensitiveannotation. This prevents values from appearing in error messages, REPL output, and trace logs. -
Validate early in CI: Run
pkl evalin your CI pipeline to catch configuration errors before deployment. The Pkl evaluator reports constraint violations with clear error messages that include the exact path to the invalid value. -
Keep Pkl files in the same repository as application code: Configuration and code that evolve together should be versioned together. Use relative paths for imports within the same project.
-
Generate format-specific outputs for each environment: Use a build script to evaluate Pkl files for each target environment and generate the appropriate output format. Store generated files in
.gitignoresince they are derived artifacts. -
Use the Pkl REPL for exploration: When designing new configuration schemas, use
pkl replto interactively test class definitions, constraints, and expressions before committing to files. -
Document with triple-slash comments: Pkl supports
///doc comments that appear in generated documentation and IDE tooltips. Document every public class, field, and function. -
Test configuration transformations: Write Pkl test files that verify constraint enforcement and default values:
// test/ConfigTest.pkl
amends "pkl:test"
import "../AppConfig.pkl"
facts {
"port rejects out of range values" {
catch(() -> new AppConfig.HttpConfig { port = 0 })
.message.contains("Expected Int in range 1..65535")
}
"default port is 8080" {
new AppConfig.HttpConfig {}.port == 8080
}
}Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Putting secrets in Pkl files | Secrets in version control | Use environment variable imports or external secret managers; mark fields with @pkl.Sensitive |
| Over-using dynamic evaluation | Hard to understand and debug | Prefer static configuration with typed classes; reserve dynamic evaluation for computed defaults |
| Forgetting to regenerate after schema changes | Stale configuration in production | Add CI steps that regenerate and validate configuration on every commit |
| Deep inheritance hierarchies | Configuration becomes hard to trace | Limit inheritance to 2-3 levels; prefer composition (mixing in modules) over deep hierarchies |
| Not pinning package versions | Inconsistent builds across environments | Always specify exact package versions in PklProject; use lock files |
| Treating Pkl files like runtime code | Over-engineering configuration | Keep configuration declarative; use functions sparingly and only for computed defaults |
Performance Optimization
Pkl evaluation is fast—typically completing in milliseconds for standard configuration files. However, for projects with many files or complex expressions, you can optimize by pre-evaluating configuration during the build step rather than at application startup:
# Pre-evaluate all configurations during build
for env in development staging production; do
pkl eval config/environments/${env}.pkl --format json -o dist/config/${env}.json
doneThis eliminates Pkl CLI invocation at runtime and produces plain JSON that loads instantly. The build step validates all constraints, so runtime loading is just JSON.parse().
Comparison with Alternatives
| Feature | Pkl | YAML | JSON | Jsonnet | CUE | Dhall |
|---|---|---|---|---|---|---|
| Type System | Strong, gradual | None | None | Dynamic | Structural | Strong, dependent |
| Validation | Built-in constraints | External tools | External tools | Runtime | Built-in | Built-in |
| Comments | Yes (//, ///) | Yes (#) | No | Yes (//) | Yes (//) | Yes (--) |
| Code Reuse | Classes, mixins, imports | Anchors/aliases (fragile) | None | Functions, imports | Unification | Imports, functions |
| Output Formats | JSON, YAML, PLIST, XML | N/A (is YAML) | N/A (is JSON) | JSON, YAML | JSON, YAML, text | Dhall (converts to JSON/YAML) |
| IDE Support | VS Code, IntelliJ | Universal | Universal | Limited | VS Code | Limited |
| Learning Curve | Moderate | Low | Very low | Moderate | High | High |
| Backed By | Apple | Community | Standard | CUE team | Community | |
| Best For | Typed, composable config | Simple config | Data interchange | Template generation | Schema validation | Safe configuration |
Advanced Patterns and Techniques
Dynamic Configuration from External Sources
Pkl can read from external sources including HTTP endpoints and environment variables, enabling dynamic configuration:
import "@pkl:env"
amends "AppConfig.pkl"
name = "order-service"
version = "2.4.1"
environment = env.read("NODE_ENV") ?? "development"
database {
host = env.read("DB_HOST") ?? "localhost"
port = Int.parse(env.read("DB_PORT") ?? "5432")
name = env.read("DB_NAME") ?? "orders"
username = env.read("DB_USER") ?? "postgres"
password = env.read("DB_PASSWORD")!
}Configuration Testing with Pkl's Test Framework
Write comprehensive tests for your configuration schemas:
// test/DatabaseConfigTest.pkl
amends "pkl:test"
import "../DatabaseConfig.pkl"
facts {
"default port is 5432" {
new DatabaseConfig { host = "db"; name = "test"; username = "u"; password = "p" }.port == 5432
}
"rejects port 0" {
catch(() -> new DatabaseConfig { host = "db"; name = "test"; username = "u"; password = "p"; port = 0 })
.message.contains("Expected Int in range 1..65535")
}
"rejects empty host" {
catch(() -> new DatabaseConfig { host = ""; name = "test"; username = "u"; password = "p" })
.message.contains("Expected String(!= \"\")")
}
"sslMode defaults to require" {
new DatabaseConfig { host = "db"; name = "test"; username = "u"; password = "p" }.sslMode == "require"
}
}Testing Strategies
Test your Pkl configuration pipeline by validating that generated outputs match expected values:
import { execSync } from 'child_process';
describe('Configuration Generation', () => {
it('should generate valid JSON for production', () => {
const result = execSync('pkl eval config/environments/production.pkl --format json', { encoding: 'utf-8' });
const config = JSON.parse(result);
expect(config.environment).toBe('production');
expect(config.database.sslMode).toBe('require');
expect(config.database.poolSize).toBeGreaterThanOrEqual(10);
});
it('should reject invalid configuration', () => {
expect(() => {
execSync('pkl eval config/test/invalid.pkl --format json', { encoding: 'utf-8', stdio: 'pipe' });
}).toThrow();
});
});Future Outlook
Pkl is still young but growing rapidly. Apple uses it extensively internally for services infrastructure, and the open-source community is building packages for Kubernetes, Terraform, AWS CDK, and other platforms. The Pkl team has signaled plans for a richer module ecosystem, improved error messages, and deeper IDE integration.
The broader trend toward typed, validated configuration is accelerating. Tools like Pkl, CUE, and Jsonnet are collectively pushing the industry away from plain-text formats and toward configuration that is treated with the same rigor as application code—typed, tested, versioned, and reviewed.
Conclusion
Pkl addresses the fundamental limitations of YAML, JSON, and TOML by providing a typed, composable, and validated configuration language. Its class system enables code reuse, its constraint system catches errors at evaluation time, and its multi-format output ensures compatibility with existing tooling.
Key takeaways:
- Pkl is a configuration language, not just a format—use it to generate JSON, YAML, or PLIST
- Classes with typed fields and constraints catch configuration errors before deployment
- The amend operator and inheritance eliminate copy-paste across environment-specific configs
- Use shared schema packages to enforce configuration contracts across teams
- Integrate Pkl evaluation into your CI/CD pipeline for early validation
- The
@pkl.Sensitiveannotation protects secrets in logs and error messages - Pkl's REPL and test framework support interactive development and automated testing
If your configuration management involves multiple environments, complex validation rules, or shared schemas across teams, Pkl is worth evaluating as a replacement for your current approach.