Top custom code preservation tools for generated SDK maintenance (September 2026)

19 min read

Every generated SDK eventually accumulates code the generator did not write: a webhook signature verifier, a convenience method that wraps three endpoints into one call, a retry policy tuned to one provider's rate limiter, a compatibility shim keeping a renamed method alive for existing consumers. None of it belongs in the OpenAPI document, and all of it lands in files the generator claims ownership of. Most teams discover the problem the same way, by losing an edit to a regeneration and then quietly turning regeneration off. What determines whether that happens is granularity: how narrow a boundary a tool can draw between generated and hand-written code. Tools that can only protect whole files force teams to freeze code they still want updated. That tradeoff, not feature count, is what separates the SDK generation platforms in this comparison.

TLDR:

  • Custom code preservation mechanisms come in four shapes: file-level ignore rules, generator-designed extension points, tracked patches reapplied by three-way merge, and nothing at all.
  • Ignore files are the cheapest mechanism and the most expensive one to live with. A file listed in .fernignore, .genignore, or .openapi-generator-ignore stops receiving generator updates permanently, including bug fixes and new endpoints.
  • Fern, Stainless, and APIMatic reapply tracked edits on top of freshly generated code with a three-way merge, so customized files keep receiving generator updates and conflicts surface as reviewable pull requests.
  • Speakeasy constrains custom code to predeclared regions inside generated files, and liblab routes it into lifecycle hooks outside them. Both avoid merges by limiting where custom code can go.
  • OpenAPI Generator offers file exclusion and template overrides, and Kiota offers neither. Without a merge layer, teams on open-source generators end up maintaining a hand-written wrapper package instead.
  • Fern combines both granularities: Fern Replay tracks line-level edits and reapplies them on every generation, while .fernignore covers the files a team owns outright.

What custom code preservation means in a generated SDK

Code generation assumes the generator owns its output directory. Regeneration is a write, not a merge, so the default behavior of every generator is to overwrite. Custom code preservation is the set of mechanisms that carve out exceptions to that rule, and they fall into four categories that behave very differently over a two-year maintenance horizon.

  • File-level exclusion. An ignore file names paths the generator must not touch. Simple, universally available, and total: the excluded file is frozen at whatever the generator last produced.
  • Designed extension points. The generator reserves specific places for custom code (a marked region inside a class body, a lifecycle hook, a partial class) and keeps ownership of everything around them.
  • Tracked patches with three-way merge. The platform records hand-written diffs as patches, regenerates from scratch, then reapplies the patches. Customized files continue to receive generator updates, and genuine collisions become merge conflicts rather than silent data loss.
  • Wrapper packages. Custom behavior lives in a separate hand-written layer that imports the generated client. No preservation mechanism is needed because nothing generated is edited, but the wrapper becomes a second SDK to maintain in every language.

The practical difference shows up on the day the API adds a field to a response model that a team has customized. Under file exclusion, the field never appears and nobody is told. Under a three-way merge, the field appears and the customization survives alongside it.

Why generated SDKs accumulate custom code

Teams do not modify generated code casually, and the reasons they do cluster into a short list. Which category a customization falls into determines where it should live.

  • Behavior plain OpenAPI cannot express. Webhook signature verification, request signing with short-lived JWTs, and bespoke retry semantics all describe client behavior that no standard OpenAPI field encodes. Some of this is recoverable through vendor extensions rather than code, which is the first thing to check.
  • Ergonomics for a specific integration. A helper that combines three calls into one, a typed convenience constructor, or a domain-specific default that saves every consumer the same three lines.
  • Backward compatibility during a migration. When a team switches SDK generators, method names and type export locations rarely match the previous library exactly. Shims preserve the old surface while consumers migrate, which matters because a renamed method is a breaking change to every downstream build.
  • Platform quirks. A polyfill for an older runtime, a proxy configuration required inside a corporate network, or a workaround for a bug in an upstream HTTP library.
  • Tests that must not be regenerated. Handwritten integration tests running against a live API are custom code by definition, and they are the most common first entry in any ignore file.

The first category is the one worth pushing back on. Pagination is the clearest example: a hand-written cursor helper is a common patch, but Fern's x-fern-pagination extension covers offset, cursor, URI, and path schemes declaratively and generates automatic pagination into SDKs and CLIs, so no token management code needs preserving at all. A customization that exists because the spec is underspecified is usually better fixed in the spec, an overlay, or generator configuration than kept forever as a patch.

What makes a strong custom code preservation mechanism in 2026?

The tools below were evaluated against the criteria that determine maintenance cost rather than first-day convenience.

  • Granularity: whether the smallest protectable unit is a repository, a file, a region, or a line.
  • Continued updates to customized files: whether a file containing custom code still receives generator improvements, new endpoints, and security fixes.
  • Conflict visibility: what happens when the generator and a customization touch the same lines, and whether the collision surfaces in code review or silently resolves one way.
  • Language parity: whether the mechanism works the same across every target language, or only the two or three the vendor built it for first.
  • Dependency handling: whether custom code can pull in a third-party package without that dependency being stripped from the manifest on the next run.
  • Auditability: whether the set of active customizations is inspectable, listable, and prunable, or whether it lives only in the diff between two directories.
  • CI compatibility: whether preservation works in an unattended pipeline, or requires a human in a dashboard.

Fern

Fern ships both granularities as first-class mechanisms, and because the line-level one operates on git history rather than language-specific markers in the output, it is not tied to a subset of target languages. Fern generates SDKs in nine: TypeScript, Python, Go, Java, C#, PHP, Ruby, Swift, and Rust.

Fern Replay handles line-level edits. It scans repository history since the last [fern-generated] commit, stores each hand-written commit as a tracked patch in .fern/replay.lock, and reapplies those patches by three-way merge on the next generation, landing them as a [fern-replay] commit stacked on top of the freshly generated one. Because the two commits are separate, a reviewer sees what the generator changed and what the patches reapplied as distinct diffs in the same pull request. Conflicts are flagged in the pull request body and resolved locally with fern replay resolve; fern replay forget untracks patches by ID or pattern when a customization is no longer needed. Replay requires mode: pull-request in the generators.yml GitHub block and is enabled by default there, which also means it cannot be used by teams generating in release or push mode without migrating output modes first.

.fernignore covers the other case: files a team owns end to end, such as handwritten integration tests, custom modules, README files, and CI workflows. Listed paths are neither modified nor recreated, with the same permanent-freeze tradeoff every ignore file carries. Fern also handles the surrounding plumbing that usually breaks custom code, including extraDependencies and extraDevDependencies in generators.yml so added packages survive regeneration, and export registration so custom TypeScript files remain importable by subpath. Fern's CLI generator extends the same Fernignore and Replay mechanisms to generated command-line tools, though CLI generation is still in alpha.

Speakeasy

Speakeasy takes the extension-point approach with custom code regions: prescribed sections of a generated file, marked with start and end comments, that the generator carries forward on each run. Python exposes an imports region and a class-body region for custom methods and properties, with equivalents in TypeScript and Java. Regions are enabled per project in .speakeasy/gen.yaml, and custom dependencies are declared through additionalDependencies so they are not stripped during regeneration.

The design tradeoff is explicit in Speakeasy's own documentation: regions exist so the generator can keep owning and updating files that contain custom code, which is what .genignore gives up. That makes regions the safer default and .genignore the escape hatch. The constraint is placement. Custom code has to fit somewhere a region already exists, so a change to the body of a generated request method, rather than an addition alongside it, falls outside the model. Region coverage is also language-by-language rather than uniform across every target.

Stainless

Stainless preserves customizations through a semantic three-way merge. Custom code is opened as a pull request against the SDK git branch matching the Stainless branch name, and on the next codegen run those edits are reapplied on top of generated code with git history intact. Conflicts open as a pull request with the collision highlighted. Stainless's own guidance is notably candid about the cost, warning that custom patches increase maintenance burden and leave the team indefinitely responsible for resolving conflicts between their patches and generator changes.

The relevant context for anyone evaluating Stainless in September 2026 is that the product is winding down. Stainless announced on May 18, 2026 that it is joining Anthropic and closing its hosted products, with new signups, projects, and SDKs shut off the same day. Teams with an existing Stainless SDK and a meaningful patch set should treat migration planning as the immediate task; the patch set is the part of the migration that does not transfer automatically, because every tool in this comparison stores customizations in its own format.

APIMatic

APIMatic added custom code injection in April 2026, and the implementation is unusually transparent about its mechanics. The CLI maintains a git source tree inside the build directory with two branches: one holding the latest generated SDK, one holding local modifications. Developers edit the SDK normally, confirm the changes, and the CLI captures the exact diff, which is reapplied on top of freshly generated code on the next run.

The supported scope covers what teams typically need, including helper methods, webhook signature verifiers, custom authentication flows, internal logging libraries, and whole additional files. Because the mechanism is git branches rather than a bespoke patch store, the state is inspectable with ordinary git tooling, which is a genuine advantage for auditability. It is also the newest preservation implementation in this comparison, so there is less accumulated evidence of how it behaves across large patch sets and long regeneration histories than for the more established options.

liblab

liblab routes custom code outside generated files entirely, through hooks that attach to the API invocation lifecycle: before a request is sent, after a response returns, and on error. Each language target gets a complete hook project where custom code lives, and that code is supplied to liblab at generation time and reintegrated into the output.

This sidesteps the merge problem cleanly, because generated files are never edited and hooks are never generated. It fits cross-cutting concerns well: rate limiting, encryption, correlation ID propagation, metrics collection, custom routing, and request or response rewriting all sit naturally at those three events. What it does not fit is anything shaped like a new public method on the client. A convenience helper that wraps three endpoints is not a request interceptor, and a hook cannot expose it in the SDK's public surface. Teams needing both patterns end up combining hooks with a wrapper package.

OpenAPI Generator

OpenAPI Generator offers customization at generation time rather than preservation after it. .openapi-generator-ignore works like .gitignore, giving finer control than the blanket --skip-overwrite flag and supporting pre-population through openapiGeneratorIgnoreList plus a full override through --ignore-file-override. Beyond exclusion, the project supports overriding Mustache templates and writing custom generator classes, which is the correct path when a customization should apply to every generated endpoint rather than one.

There is no merge layer. As the documentation makes plain, the ignore file prevents regeneration of the listed files without merging custom code into regenerated content. The common convention is to generate into something like src/main/gen, keep hand-written code in src/main/java, and use ignore entries to stop the generator recreating files that moved. That makes the separation explicit, but it is a wrapper-package architecture with extra steps. The upside is that it is free, self-hosted, and covers far more language targets than any commercial option.

Microsoft Kiota

Kiota takes the strictest position: the generated folder is fully managed by the generator, and any change there is overwritten on the next run. There is no ignore file, no code region, and no patch store. The one concession is language-native. Kiota now emits partial class model declarations for C#, which is the idiomatic .NET mechanism for extending generated types from a separate file that the generator never touches.

Outside of that, the supported pattern is to keep every customization out of the generated tree, typically as helper classes, dependency injection extensions, and HTTP handlers attached to the client builder. For request and response middleware that model is entirely adequate, since Kiota's handler pipeline is the intended extension surface. For teams on languages without a partial-class equivalent, a wrapper layer should be treated as part of the architecture rather than a workaround.

Feature comparison table of custom code preservation approaches

ToolPreservation mechanismFinest granularityCustomized files keep updatingConflict handling
FernReplay patches (three-way merge) plus .fernignoreLineYes, with ReplayFlagged in the PR, fern replay resolve
SpeakeasyCustom code regions plus .genignorePredeclared regionYes, with regionsNot applicable, regions are reserved
StainlessSemantic three-way merge on a branchLineYesConflict surfaced as a PR
APIMaticGit branch diff reapplied on regenerationLineYesStandard git merge resolution
liblabLifecycle hooks outside generated codeLifecycle eventYes, hooks are never generatedNot applicable
OpenAPI Generator.openapi-generator-ignore, template overridesFileNoNot applicable, no merge
KiotaNone inside the generated tree, partial classes in C#Repository layoutNot applicableNot applicable

The column that predicts long-term maintenance cost is the third one. Every tool can protect a customization once. Only the merge-based options can protect it without also freezing the code around it.

How three-way merge preservation behaves at regeneration time

A three-way merge needs three inputs: a base, a local version, and a remote version. In SDK generation the base is the previously generated output, the local version is that output plus hand-written edits, and the remote version is the newly generated output. The merge computes the local diff and replays it against the new base. This is why the mechanism is sometimes described as replay rather than merge, and why the implementations differ mainly in how they identify the base.

Fern derives its base from git history, anchoring on the last [fern-generated] commit and treating every customer commit since then as a tracked patch. That anchoring choice has a useful consequence: because the anchor is re-derived from git log on every run rather than stored as a fixed hash, force-pushed branches and rewritten history continue to work, and a replay pull request that is closed without merging simply causes the next generation to re-derive its anchor with no manual cleanup. APIMatic anchors on a dedicated branch in a local source tree instead, and Stainless anchors on its branch naming convention.

Three failure patterns are worth planning for regardless of vendor.

  • True line collisions. The generator and a patch modified the same lines, usually because a hand-edited docstring or method body was later changed in the spec. These are unavoidable and correctly surfaced as conflicts.
  • Patch rot. A patch targeting code the generator no longer emits at all. The merge may apply cleanly into a file that no longer means what it did, or fail in a way that is hard to read. Periodically listing and pruning tracked patches is the mitigation, which is what a command like fern replay forget exists for.
  • Semantic drift without a textual conflict. A patch applies cleanly but is now wrong, because a generated function it calls changed signature or semantics. No merge algorithm catches this, which is why the CI practices described below matter more than the merge implementation does.

The long-term cost of file-level ignore rules

Ignore files are the correct tool for a narrow case: a file a team wrote entirely, that the generator has no opinion about, and that should never be generated. Tests, CI workflows, README files, and license files fit that description exactly. The failure happens when an ignore entry is used to protect a small edit inside a file the generator actually maintains.

The cost is not paid at the moment the entry is added. It accrues.

  • Missed endpoint coverage. New endpoints added to the spec do not appear in an ignored client file. Nothing fails; the method is simply absent, and the gap surfaces as a support ticket from a developer who read the docs.
  • Missed correctness fixes. SDK generators ship meaningful runtime fixes: retry and backoff behavior, pagination edge cases, null-versus-absent handling in PATCH request bodies, discriminated union narrowing. An ignored file keeps the old behavior indefinitely.
  • Cross-language divergence. Ignore entries are added per language, usually by whoever hit the problem first. Two years in, the Python SDK behaves differently from the Go SDK for reasons nobody documented.
  • Invisible scope. An ignore file lists paths, not reasons. It does not record which line inside the file mattered, so nobody can safely remove an entry later, and the exclusion outlives the customization that justified it.

A reasonable operating rule: treat every ignore entry covering a generator-owned file as a bug with a ticket attached, and resolve it by moving the customization to a patch, an extension point, or upstream into the spec.

A decision ladder for where a customization belongs

Preservation should be the last resort, not the first move. Working down this ladder puts each customization at the lowest-maintenance level that can express it.

  1. Fix it in the API definition. If a response type is wrong or an operation is missing metadata, the SDK is correctly reflecting a bad contract. Change the contract.
  2. Use a spec extension or overlay. Behavior that looks like client code is often declarative: pagination, retry behavior, and idempotency all have x-fern- extensions. When the source spec is generated upstream and cannot be edited directly, the OpenAPI Overlay Specification, released by the OpenAPI Initiative in October 2024, layers changes on top without touching it, and Fern supports overlays alongside its own overrides files.
  3. Use generator configuration. Method naming, group prefixes, audience tagging, and extra dependencies are configuration, not code. Anything solvable in generators.yml or its equivalent should never become a patch.
  4. Use a designed extension point. Request and response middleware, hooks, and reserved code regions exist for cross-cutting behavior. Prefer them over editing a method body.
  5. Use a tracked patch. For a genuine addition to the generated surface, a Replay patch or semantic merge keeps the customization while the surrounding file keeps updating.
  6. Use an ignore entry. Reserve it for files owned outright, and document why.
  7. Write a wrapper package. Justified when the custom layer is substantial and stable enough to be its own artifact with its own tests, and accepted as a per-language maintenance commitment.

Keeping custom code verifiable in CI

Preservation mechanisms guarantee that custom code still exists after regeneration. They do not guarantee it still works, and in continuously regenerated SDKs closing that gap takes three habits.

Handwritten integration tests running against the real API are the most direct check, and they need to be protected from regeneration themselves. Fern supports adding handwritten tests directly to SDK repositories without them being overwritten, layered alongside generated unit and mock tests. A patch that applies cleanly but calls a changed function signature fails at that test, which is exactly where it should fail.

Review the two halves of a regeneration separately. When generated output and reapplied patches land as distinct commits in one pull request, a reviewer can read the generator diff on its own and ask whether any patch depends on what changed. Collapsed into a single diff, that question is unanswerable in practice.

Run breaking-change detection on the spec before the SDK is built, not after it is published. A customization written to preserve a method name is a signal that the contract moved; detecting the move upstream turns a silent patch into an explicit versioning decision. Pair that with a release gate so package publishing never happens on a regeneration whose patches did not cleanly reapply.

Why Fern fits generated SDK maintenance with custom code

Fern is the strongest fit when a team needs both granularities across a wide language matrix. Replay tracks line-level edits and reapplies them by three-way merge on every generation, so a customized file keeps receiving new endpoints and generator fixes, and conflicts arrive as a reviewable pull request rather than a lost edit. .fernignore covers the files a team owns outright, including handwritten integration tests that run against a live API. Both mechanisms work identically across all nine supported SDK languages, and the supporting configuration (extra dependencies, export registration, method naming) removes the most common reasons a customization would be needed in the first place.

Final thoughts on custom code preservation for generated SDKs

The right tool is the one that draws the narrowest boundary the customization actually needs. File-level exclusion is available everywhere and is the correct answer for files a team wrote entirely, but using it to protect a few lines freezes a file that should keep improving, and that decision compounds quietly across languages and years. Extension points and tracked patches both avoid that cost, differing in whether the generator reserves the space in advance or reconciles the edit afterward. Before reaching for any of them, work down the ladder: a customization that can be expressed in the spec, an overlay, or generator configuration should never become code to preserve. To see how Replay and .fernignore handle an existing patch set across nine languages, book a demo.

FAQ

How do you preserve custom code when regenerating an SDK from OpenAPI?

Three mechanisms are in general use. An ignore file (.openapi-generator-ignore, .genignore, .fernignore) excludes whole files from generation permanently. A designed extension point, such as a Speakeasy code region or a liblab hook, reserves a place for custom code that the generator routes around. A tracked patch system records hand-written diffs and reapplies them by three-way merge after regeneration, which is what Fern Replay, Stainless, and APIMatic do. Only the third keeps a customized file receiving generator updates.

What is the difference between .fernignore and Fern Replay?

.fernignore operates on whole files and is total: listed paths are neither modified nor recreated, and they stop receiving generator updates for good. Fern Replay operates on lines, storing each hand-written commit as a patch in .fern/replay.lock and reapplying it on top of freshly generated code. Use .fernignore for files owned end to end, such as integration tests and CI workflows, and Replay for edits inside files the generator should keep maintaining.

Is it better to fork a generated SDK or patch it?

Patching is almost always better, because a fork stops receiving every downstream improvement at once: new endpoints, retry fixes, serialization corrections, and security updates. A fork is defensible only when the custom layer is large and stable enough to justify its own artifact and test suite, and when it will not need to track API changes. The intermediate option is a wrapper package that imports the generated client rather than replacing it, which keeps regeneration working while giving custom behavior its own home.

What happens to custom code when a generated SDK hits a merge conflict?

With a merge-based system, the conflict is reported rather than resolved silently. Fern flags conflicts in the pull request body and provides fern replay resolve for local resolution; Stainless opens a pull request with the collision highlighted; APIMatic surfaces it as an ordinary git merge conflict. Conflicts generally mean the generator and a patch changed the same lines, which is a signal that the customization may now be redundant or that the underlying contract moved.

Should custom SDK logic live in hooks or in the generated client?

Cross-cutting behavior belongs in hooks or middleware: rate limiting, logging, correlation IDs, encryption, metrics, and request or response rewriting all attach cleanly to the request lifecycle and need no preservation mechanism at all. Anything that should appear on the client's public surface, such as a convenience method wrapping several endpoints or a typed helper constructor, cannot be expressed as a hook and needs either a tracked patch or a wrapper package. Most mature SDKs use both.