Speakeasy Logo
Skip to Content

Engineering

Why We Built Our Own OpenAPI Linter (And How It Compares)

Tristan Cartledge

Tristan Cartledge

February 9, 2026 - 16 min read

Engineering

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:oas ruleset 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 all dumps every rule with descriptions and fix guidance. It’s one of the best “what can this tool check?” experiences.
  • Interactive TUI dashboard. vacuum dashboard lets 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 --summary flag 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, or nullable-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 --watch mode 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

Spec
Petstore
Lines
3,223
Size
104 KB
Source
Classic OpenAPI example
Neon
Lines
6,373
Size
204 KB
Source
Neon serverless Postgres API
Mistral AI
Lines
8,434
Size
240 KB
Source
Mistral AI inference API
LaunchDarkly
Lines
48,456
Size
1.6 MB
Source
LaunchDarkly feature flag API
DigitalOcean
Lines
73,346
Size
2.4 MB
Source
DigitalOcean public cloud API
Plaid
Lines
81,366
Size
3.0 MB
Source
Plaid financial data API

All specs are publicly available from their respective companies.

Performance: Time and Memory

CLI Performance — Time (Wall Clock)

Spec
Petstore (3K lines)
Speakeasy
0.17s ✦
Vacuum
1.37s
Spectral
1.22s
Neon (6K lines)
Speakeasy
0.21s
Vacuum
0.16s ✦
Spectral
2.07s
Mistral (8K lines)
Speakeasy
0.59s ✦
Vacuum
0.63s
Spectral
2.56s
LaunchDarkly (48K lines)
Speakeasy
1.10s ✦
Vacuum
1.40s
Spectral
10.00s
DigitalOcean (73K lines)
Speakeasy
1.56s ✦
Vacuum
3.01s
Spectral
29.97s
Plaid (81K lines)
Speakeasy
1.67s ✦
Vacuum
2.39s
Spectral
30.34s

CLI Performance — Peak Memory (RSS)

Spec
Petstore (3K lines)
Speakeasy
38 MB ✦
Vacuum
67 MB
Spectral
151 MB
Neon (6K lines)
Speakeasy
55 MB ✦
Vacuum
81 MB
Spectral
159 MB
Mistral (8K lines)
Speakeasy
88 MB ✦
Vacuum
146 MB
Spectral
161 MB
LaunchDarkly (48K lines)
Speakeasy
235 MB ✦
Vacuum
421 MB
Spectral
317 MB
DigitalOcean (73K lines)
Speakeasy
261 MB ✦
Vacuum
734 MB
Spectral
870 MB
Plaid (81K lines)
Speakeasy
376 MB ✦
Vacuum
675 MB
Spectral
405 MB

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)

Spec
Neon (6K lines)
Speakeasy
104ms
Vacuum
165ms
Speakeasy Faster By
1.6x
Mistral (8K lines)
Speakeasy
274ms
Vacuum
366ms
Speakeasy Faster By
1.3x
LaunchDarkly (48K lines)
Speakeasy
866ms
Vacuum
1,527ms
Speakeasy Faster By
1.8x
DigitalOcean (73K lines)
Speakeasy
1,360ms
Vacuum
4,665ms
Speakeasy Faster By
3.4x
Plaid (81K lines)
Speakeasy
1,639ms
Vacuum
3,570ms
Speakeasy Faster By
2.2x

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

Spec
Petstore (3K lines)
Speakeasy
149
Vacuum
358 ✦
Spectral
164
Neon (6K lines)
Speakeasy
1,492 ✦
Vacuum
1,337
Spectral
1,071
Mistral (8K lines)
Speakeasy
2,610 ✦
Vacuum
2,291
Spectral
1,775
LaunchDarkly (48K lines)
Speakeasy
11,061
Vacuum
11,290 ✦
Spectral
7,027
DigitalOcean (73K lines)
Speakeasy
9,323 ✦
Vacuum
8,778
Spectral
4,435
Plaid (81K lines)
Speakeasy
21,475 ✦
Vacuum
14,980
Spectral
6,848

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/releases

Lint 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 json

Configure 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/*.ts

Use 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.yaml

Use 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.

Last updated on

Build with
confidence.

Ship what's next.