Engineering
Why We Built Our Own OpenAPI Linter (And How It Compares)
Tristan Cartledge
February 9, 2026 - 16 min read
APIs are the backbone of modern software. The OpenAPI specifications that define them are growing. Enterprise specs routinely exceed 50,000 lines. If your linter can’t keep up, it becomes a bottleneck: too slow for pre-commit hooks, too heavy for CI, and too frustrating for developers iterating on their API design.
We built our own OpenAPI linter because the existing tools each hit a wall somewhere. Spectral has the established ecosystem, but brings Node.js as a dependency and slows to a crawl on large specs. Vacuum is fast and Go-native, but uses significantly more memory. Neither struck the balance we needed: fast enough for every save, light enough for constrained CI environments, and thorough enough to catch real issues.
Today we’re open-sourcing the linter as part of our openapi CLI. This post walks through the landscape, the benchmarks, and the engineering tradeoffs we made. We’ll be fair about where the alternatives are strong and let the numbers speak for themselves.
The Problem: Large Specs Break Linters
If you’re working with a small OpenAPI spec, a handful of API endpoints and a few thousand lines, every linter works fine. The differences are academic. You pick whichever has the integration you prefer and move on.
But specs don’t stay small. As APIs mature, they accumulate endpoints, schemas, examples, and security definitions. A typical enterprise API can easily reach 50,000 to 80,000+ lines. At that scale, linting performance starts to matter in ways that affect your daily workflow:
- Pre-commit hooks need to run in under a few seconds or developers will bypass them.
- CI pipelines have memory limits. GitHub Actions runners default to 7 GB of RAM, but if you’re running linting alongside builds, tests, and other checks, you’re sharing that budget.
- Editor integrations that lint on save need near-instant feedback to be useful.
We needed a linter that could handle our largest customer specs without breaking a sweat.
What’s Already Out There
Before building our own, we evaluated the two main options in the OpenAPI linting space. Both are capable tools with real strengths.
Spectral
Spectral by Stoplight is the incumbent. It’s JavaScript-based, the most widely used OpenAPI linter, and for good reason:
- Largest ecosystem. Twelve output formats including SARIF, JUnit, GitHub Actions, HTML, code-climate, GitLab, and markdown. If you need a specific integration, Spectral probably has it.
- Most extensible. Custom rules are JavaScript functions. The barrier to writing a new rule is low.
- The de facto standard. The
spectral:oasruleset is what other tools reference when they talk about compatibility. It defined the category. - Wide IDE support. VS Code extension, CI templates, and a mature plugin ecosystem.
Where Spectral struggles is scale. It requires a Node.js runtime, which adds a dependency to Go, Python, or Java CI pipelines that don’t otherwise need it. Its OWASP security rules require a separate npm install. And as we’ll show in the benchmarks, it slows dramatically on large specs — up to 19x slower than our linter on specs above 70,000 lines.
Spectral also has fewer built-in rules than you might expect. Rules like paths-kebab-case, component-description, no-verbs-in-path, description-duplication, operation-4xx-response, no-ambiguous-paths, and oas3-example-missing aren’t included out of the box. You’d need to write custom rules to cover these.
There’s also the question of ongoing maintenance. Spectral doesn’t truly support OpenAPI 3.2. It won’t fail on a 3.2 spec, but it silently ignores 3.2-specific constructs, which means you’re getting incomplete coverage without any warning. That gap on its own would be manageable, but it points to a larger concern: since SmartBear acquired Stoplight, Spectral has received minimal investment. The project is largely dormant at this point, with few meaningful updates and little indication that 3.2 support or other improvements are on the way. Use at your own risk.
Vacuum
Vacuum by Dave Shanley (a.k.a Princess B33F) is a Go-native alternative. It was built explicitly as a faster replacement for Spectral, and it delivers on that promise in several ways:
- Go-native, single binary. No runtime dependencies. Same deployment story as our linter.
- Excellent rule discovery.
vacuum generate-ruleset alldumps every rule with descriptions and fix guidance. It’s one of the best “what can this tool check?” experiences. - Interactive TUI dashboard.
vacuum dashboardlets you browse results interactively in the terminal. - Quality Score metric. A 0–100 score with a letter grade that gives you a quick health check of your spec.
- Highest rule count. Around 88 built-in rules, more than either Spectral or our linter.
- OWASP rules built-in. No extra install step.
- Spectral-compatible rulesets. You can reuse existing Spectral ruleset configurations.
We at Speakeasy were long time users of Vacuum, and remain big fans of Princess B33F Industries. Ultimately, our motivation to graduate from Vacuum was down to resource usage. It became the performance bottleneck in our code generator and so we decided to see if we could improve with our own linter.
Our Approach
We built the linter as part of our existing openapi Go library. It’s the same library that powers our SDK generation platform and processes thousands of specs daily. If you’ve read our previous post on building the library, the linter sits on top of the same architecture: the reflection-based marshaller, the node graph, the walker.
Here’s what we optimized for:
- Performance without sacrificing coverage. Built-in rules covering style, semantics, OWASP security, and schema validation. We find more issues than both alternatives on most specs, not fewer.
- Go-native, single binary. No Node.js, no npm, no runtime dependencies. Install it and run it.
- Developer experience. Every rule includes descriptions, documentation links, and — for documented rules — good/bad examples and rationale. The
--summaryflag gives you a per-rule breakdown sorted by count so you can prioritize fixes. - Extensibility. Custom rules are written in TypeScript, transpiled by esbuild, and executed in a Go-embedded JavaScript runtime (goja). You get the ergonomics of TypeScript without the Node.js dependency.
64 Rules Across Four Categories
Our built-in rules are organized into four categories:
- Style rules enforce naming conventions and structural consistency —
paths-kebab-case,operation-operationId,tag-description,info-contact, and others. These are the rules that keep a multi-team API looking like it was written by one team. - Semantic rules catch logical issues in your spec —
no-ambiguous-paths,no-verbs-in-path,semantic-link-operation,operation-4xx-response. These go beyond syntax and check whether your API design makes sense. - OWASP security rules are built-in, not a separate install. They check for common API security issues like missing rate limiting headers, overly permissive CORS, missing authentication, and integer overflow risks. You get the full OWASP API Security Top 10 coverage out of the box.
- Schema validation rules check your JSON Schema usage —
oas3-example-missing,component-description,description-duplication. These ensure your schemas are well-documented and consistent.
Every rule ships with a human-readable description, a documentation link, and a default severity. Documented rules additionally include good and bad YAML examples, a rationale for why the rule exists, and whether an auto-fix is available. Some rules support --fix to automatically apply corrections — you can preview with --dry-run before committing to changes.
Rule Discovery and Prioritization
Two CLI features make it easy to work with the rules:
openapi spec list-rules gives you full rule discovery from the command line. It shows every available rule with its metadata, fix guidance, and which rulesets it belongs to. You can filter by --category (style, semantic, security, schemas) or --ruleset (recommended, security, all), and pipe JSON output with --format json to other tools.
openapi spec lint --summary produces a per-rule summary table after linting. Instead of scrolling through thousands of individual findings, you see which rules produced the most findings, sorted by count. This makes it easy to prioritize: if oas3-example-missing accounts for 60% of your findings, you know where to focus first.
Custom Rules in TypeScript
When the built-in rules aren’t enough, you can write custom rules in TypeScript. The rules are transpiled by esbuild at lint time and executed in goja (a Go-embedded JavaScript runtime), so there’s no Node.js dependency even for custom rules.
A custom rule receives the parsed document and returns an array of findings. You have full access to the document model — operations, schemas, parameters, headers — and can implement any check that the built-in rules don’t cover.
What We Deliberately Left Out
We’re honest about what we don’t have yet:
- Fewer built-in rules than Vacuum (64 vs ~88). We don’t have some of Vacuum’s extras like
camel-case-properties,no-unnecessary-combinator,circular-references, ornullable-enum-contains-null. - Fewer output formats than Spectral (text + JSON vs 12 formats). SARIF, JUnit, and GitHub Actions formats are on the roadmap but not here today.
- No JSONPath in output. This is a deliberate performance choice — see below.
- No interactive dashboard or TUI mode.
- No
--watchmode for automatic re-linting on file changes.
Why No JSONPath?
Both Vacuum and Spectral include the JSONPath to each finding in their output (e.g., $.paths["/v2/1-clicks"].get.responses). We deliberately omit this.
Computing JSONPath for every finding adds significant overhead. It requires maintaining a mapping from every YAML node to its full document path during parsing, which increases both memory usage and processing time. For a tool focused on being the fastest linter, this is a trade-off we’ve chosen not to make.
Our JSON output provides line and column for every finding, which is sufficient for IDE integration, CI annotations, and human navigation. If we add JSONPath in the future, it will likely be opt-in via a flag to avoid penalizing users who don’t need it.
The Benchmarks
We ran all three linters against six publicly available OpenAPI specs ranging from 3,200 to 81,000 lines. Each tool was configured with the closest equivalent ruleset — rules with no equivalent in another tool were disabled to keep the comparison fair. Each tool was run three times per spec and results were averaged. Time was measured via /usr/bin/time -v (wall clock), memory via peak RSS.
Specs Tested
Benchmark Specs
All specs are publicly available from their respective companies.
Performance: Time and Memory
CLI Performance — Time (Wall Clock)
CLI Performance — Peak Memory (RSS)
The ✦ marks the best result in each row. Lower is better for both time and memory.
The Gap Widens With Scale
On small specs (3–8K lines), all three tools complete in under 3 seconds. The differences are negligible — pick whichever fits your workflow. But as specs grow to real-world enterprise sizes, the scaling behavior diverges dramatically:
- At 48K lines: We’re 1.3x faster than Vacuum, 9x faster than Spectral
- At 73K lines: We’re 1.9x faster than Vacuum, 19x faster than Spectral
- At 81K lines: We’re 1.4x faster than Vacuum, 18x faster than Spectral
The pattern tells a clear story. Spectral’s time grows roughly linearly with spec size, which means a 10x larger spec takes roughly 10x longer. Vacuum scales better but still shows increasing overhead on large specs. Our linter’s time grows sublinearly — the 81K-line Plaid spec takes only about 10x longer than the 3K-line Petstore, despite being 25x larger.
This matters because the specs that need linting most — large, complex APIs with many contributors — are exactly the ones where performance differences compound. A 30-second lint run in CI isn’t just slow, it’s slow enough that developers start skipping it or moving it to nightly builds where findings are discovered too late to fix cheaply.
A Note on Small-Spec CLI Times
You’ll notice Vacuum clocks 0.16s on the Neon spec vs our 0.21s. At the CLI level, Vacuum has lower startup overhead (~30ms vs our ~100ms). Our CLI pre-registers 400+ marshaller type factories at import time for all supported formats (OpenAPI 3.x, Swagger 2.0, Arazzo, Overlay). This is a deliberate trade-off: it gives us 892x faster unmarshaling compared to reflection. On any real-world spec, the linting work dominates and startup is negligible.
To confirm this, we ran Go library-level benchmarks (testing.B) that eliminate CLI startup overhead entirely:
Library-Level Benchmarks (Go testing.B)
At the library level, we’re faster on every spec — including the ones where Vacuum’s CLI appears to tie or win.
Memory Efficiency
Our linter uses less memory on every spec tested:
- vs Vacuum: 1.5–2.8x less memory across all specs. On the DigitalOcean spec (73K lines), Vacuum peaks at 734 MB while we use 261 MB — that’s 473 MB of difference on a single lint run.
- vs Spectral: 1.1–3.3x less memory, though Spectral’s numbers vary more due to Node.js garbage collection behavior. On the same DigitalOcean spec, Spectral hits 870 MB.
Why does this matter? CI environments have memory limits. GitHub Actions runners give you 7 GB, but that’s shared across your entire workflow — builds, tests, linting, and any other checks running in parallel. If your linter alone consumes 870 MB, that’s a meaningful chunk of your budget. In more constrained environments — small Kubernetes pods, self-hosted runners with 2–4 GB of RAM, or Docker containers with tight limits — the difference between 261 MB and 734 MB can mean the difference between a passing run and an OOM-killed process.
We Find More Issues
Despite being faster and lighter, our linter consistently finds equal or more issues than both alternatives on most specs. Raw finding count isn’t a perfect proxy for quality — severity distribution and false-positive rates matter too — but it shows that speed hasn’t come at the cost of coverage:
Findings Count by Spec
Vacuum finds more on Petstore (it has extra rules we don’t match, like circular-references and nullable-enum-contains-null) and LaunchDarkly (primarily from description-duplication which triggers differently in their implementation). We find more on every other spec, primarily because our oas3-example-missing rule has broader coverage and we have rules like semantic-link-operation that neither alternative implements.
Spectral consistently finds the fewest issues because it lacks built-in rules for several categories — paths-kebab-case, component-description, no-verbs-in-path, description-duplication, operation-4xx-response, no-ambiguous-paths, and oas3-example-missing. You’d need to write custom JavaScript rules to cover these.
No Runtime Dependencies
Both our linter and Vacuum are single Go binaries — download, run, done. Spectral requires Node.js and npm, and its OWASP security rules are a separate package (@stoplight/spectral-owasp-ruleset). For teams not already running Node.js in CI, that’s meaningful friction. Our OWASP rules and Vacuum’s are both built-in.
Where the Others Still Win
We want to be clear about where the alternatives are stronger today:
Spectral has the best ecosystem. If you need SARIF output for GitHub Advanced Security, JUnit reports for your test dashboard, or GitLab code quality integration, Spectral has it now. Its custom rule system is also the most flexible — any JavaScript function can be a rule, while we require TypeScript that compiles to a specific interface.
Vacuum has the most built-in rules (~88 vs our 64) and the best interactive experience with its TUI dashboard. Its vacuum generate-ruleset all command is also excellent for discovering what’s available. If you want the broadest out-of-the-box coverage and don’t mind the higher memory usage, Vacuum is a solid choice.
We’re working to close these gaps. SARIF and JUnit output formats are on our near-term roadmap. We’ll continue expanding our ruleset. But we’d rather ship fewer rules that are fast and well-documented than pad the count with rules that slow things down.
Getting Started
Install
# macOS / Linux (Homebrew)
brew install openapi
# Go install
go install github.com/speakeasy-api/openapi/cmd/openapi@latest
# Shell script (Linux / macOS)
curl -fsSL https://raw.githubusercontent.com/speakeasy-api/openapi/main/scripts/install.sh | bash
# Or download the binary from GitHub releases
# https://github.com/speakeasy-api/openapi/releasesLint a Spec
# Lint with default rules (uses the "all" ruleset)
openapi spec lint openapi.yaml
# Lint with a specific ruleset
openapi spec lint --ruleset recommended openapi.yaml
# Lint with summary output — shows per-rule breakdown sorted by count
openapi spec lint --summary openapi.yaml
# Output as JSON for CI integration
openapi spec lint --format json openapi.yaml
# Disable specific rules
openapi spec lint --disable style-info-contact --disable style-tag-description openapi.yaml
# List all available rules with metadata
openapi spec list-rules
# List rules filtered by category or ruleset
openapi spec list-rules --category security
openapi spec list-rules --ruleset recommended
# List rules as JSON for scripting
openapi spec list-rules --format jsonConfigure Rules
Create a lint.yaml config file (default location: ~/.openapi/lint.yaml, or specify with --config):
# lint.yaml
# Extend one or more rulesets
extends: recommended
# Per-category overrides
categories:
security:
enabled: true
severity: error
style:
severity: warning
# Per-rule overrides
rules:
- id: style-paths-kebab-case
severity: warning
- id: style-info-contact
disabled: true
- id: semantic-no-ambiguous-paths
severity: error
# Custom TypeScript rules (optional)
custom_rules:
paths:
- ./rules/*.tsUse in CI
# .github/workflows/lint.yaml
name: Lint OpenAPI
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install openapi CLI
run: |
curl -fsSL https://raw.githubusercontent.com/speakeasy-api/openapi/main/scripts/install.sh | bash
- name: Lint
run: openapi spec lint --config lint.yaml openapi.yamlUse as a Go Library
If you’re building Go tooling that needs to lint specs programmatically:
package main
import (
"context"
"errors"
"fmt"
"os"
baseLinter "github.com/speakeasy-api/openapi/linter"
"github.com/speakeasy-api/openapi/openapi"
openapiLinter "github.com/speakeasy-api/openapi/openapi/linter"
"github.com/speakeasy-api/openapi/validation"
)
func main() {
ctx := context.Background()
f, err := os.Open("openapi.yaml")
if err != nil {
panic(err)
}
defer f.Close()
doc, validationErrs, err := openapi.Unmarshal(ctx, f)
if err != nil {
panic(err)
}
// Configure the linter with the "recommended" ruleset
config := &baseLinter.Config{
Extends: []string{"recommended"},
}
linter, err := openapiLinter.NewLinter(config)
if err != nil {
panic(err)
}
// Run the linter
docInfo := baseLinter.NewDocumentInfo(doc, "openapi.yaml")
output, err := linter.Lint(ctx, docInfo, validationErrs, nil)
if err != nil {
panic(err)
}
// Iterate over findings
for _, result := range output.Results {
var ve validation.Error
if errors.As(result, &ve) {
fmt.Printf("[%d:%d] %s %s %s\n",
ve.GetLineNumber(), ve.GetColumnNumber(),
ve.GetSeverity(), ve.Rule, ve.Error())
}
}
fmt.Printf("\nTotal findings: %d\n", len(output.Results))
}Benchmark Methodology and Reproducibility
For full transparency, here’s how we ran the benchmarks:
- Tool versions: openapi v1.16.0, vacuum v0.23.8, spectral v6.15.0
- Platform: Linux (WSL2), Go 1.24
- Date: 2026-02-08
- Methodology: Each tool configured with the closest equivalent ruleset. Rules unique to one tool were disabled. Each tool run 3 times per spec, results averaged. Time measured via
/usr/bin/time -v(wall clock), memory via peak RSS. - Specs: All six specs are publicly available from their respective companies. We’re happy to share our exact benchmark setup — reach out on GitHub if you want to reproduce the results.
The Bigger Picture
We didn’t build this linter in isolation. It’s one piece of a larger set of open-source OpenAPI tooling that includes parsing, validation, bundling, inlining, overlays, optimization, and now linting. The same Go library that powers the linter also powers our SDK generation platform, which processes thousands of specs daily across every shape and size of API.
That matters because a linter is only as good as its understanding of the spec. Our linter doesn’t use a separate parser or a simplified model — it runs on the same full-fidelity document graph that we use for code generation. When we improve reference resolution, validation, or schema handling in the library, the linter gets better automatically.
If you’re building Go tooling on top of OpenAPI — linters, documentation generators, gateways, test harnesses, or CI checks — the linter is a good example of what the library makes possible. And if you just need a fast linter for your CI pipeline, you don’t need to know or care about the library at all.
Wrapping Up
Spectral built the category. Vacuum proved Go could do it faster. We took it further.
1.7 seconds to lint an 81,000-line spec. 376 MB of memory. More findings than either alternative. No runtime dependencies.
We didn’t want to trade coverage for speed. Our linter finds more issues, not fewer. And we didn’t want to trade honesty for marketing — Spectral has a better ecosystem, Vacuum has more rules, and we have work to do on output formats and tooling. We’re shipping improvements every week.
If your OpenAPI specs are growing and your current linter is starting to feel slow, give the Speakeasy openapi CLI a try. It’s open source, it’s fast, and it’s built by a team that processes thousands of specs every day.
Check out the code on GitHub , browse the rule documentation , or open an issue if something’s missing. We’re actively developing this and want to hear what you need.