
Most of what modern software engineers do involves APIs: public interfaces for communicating with a program, like this one from Twilio. I’ve spent a lot of time…
Most of what modern software engineers do1 involves APIs: public interfaces for communicating with a program, like this one from Twilio. I’ve spent a lot of time working with APIs, both building and using them. I’ve written public APIs for third-party developers, private APIs for internal use (or consumption by a single frontend page), REST and GraphQL APIs, and even non-network interfaces like the ones for command-line tools.
Like designing good software systems, I think much of the advice floating around about API design is too fancy. People get wrapped up in what “real” REST is, or whether HATEOAS is a good idea, and so on. This post is my attempt at writing down everything I know about designing good APIs.
If this is true about systems - and it is - it’s even more true about APIs: good APIs are boring. An API that’s interesting is a bad API (or at least it would be a better one if it were less interesting). For the developers who build them, APIs are complex products that they spend time designing and polishing. But for the developers who use them, APIs are tools that they use in order to accomplish some other goal. Any time they spend thinking about the API instead of about that goal is time wasted. From their perspective, an ideal API should be so familiar that they will more or less know how to use it before they read any documentation2.
However, one big difference from most software systems is that APIs are hard to change. Once you publish an API and people start using it, any change to the interface will break your users’ software. Of course, it is possible to make changes. But (as I’ll say below) each change imposes a serious cost: every time you force your users to update their software, they will give serious thought to using a different API that’s more stable. That gives API-builders a strong incentive to design carefully and get it right the first time.
This tension leads to an interesting dynamic for engineers who build APIs. On the one hand, they want to build the simplest API possible. On the other hand, they want to do clever things to maintain flexibility long-term. In broad strokes, API design is about finding a balance between those two incompatible goals.
What happens when you need to make changes to your API? Additive changes - for instance, putting a new field in the response - are typically fine. There are some consumers which will blow up if they’re getting more fields than they expect, but in my view this is irresponsible. You should expect API consumers to ignore unexpected fields (sensible JSON-parsing typed languages do this by default).
However, you can’t remove or change the types of fields. You can’t change the structure of existing fields (for instance, moving user.address to user.details.address in the JSON response). If you do, every single piece of code that relies on those fields will immediately break. Consumers of that code will report it as a bug, and the maintainers of the code will (when they figure it out) be rightfully furious that you deliberately broke their software.
The principle here is something like Linus Torvalds’ famous slogan WE DO NOT BREAK USERSPACE. As a maintainer of an API, you have something like a sacred duty to avoid harming your downstream consumers. The norm is so strong because so much software depends on so many APIs (which in turn depend on upstream APIs, and so on). One careless API maintainer far enough upstream can break hundreds or thousands of pieces of software downstream.
You should never make a change to an API just because it’d be neater, or because it’s a little awkward. The “referer” header in the HTTP specification is famously a misspelling of the word “referrer”, but they haven’t changed it, because we do not break userspace.
It’s honestly hard to think of examples where an API really needs a breaking change. But sometimes the technical value of a change is high enough that you decide to bite the bullet and do it anyway. In those cases, how can you change your API responsibly? The answer is versioning.
API versioning means “serve both the old and new version of your API at the same time”. Existing consumers can continue to use the old version, while new consumers can opt-in to the new one. The easiest way to do this is to include something like /v1/ in your API url. OpenAI’s chat API is at v1/chat/completions, so if they ever want to totally rework the structure, they can do that in v2/chat/completions and keep the existing consumers working.
Once you have the new and old version working simultaneously, you can start telling users to upgrade to the new version. This takes a long time: months or even years. Even with banners on the website, docs, custom emails, and headers on the API response, when you finally remove the old version, you will still get a lot of angry users upset that you’ve broken their software. But at least you’ll have done what you can about it.
There are lots of other ways to do API versioning. The Stripe API does versioning in a header, and lets accounts set their default version in the UI. But the principle is the same - any consumers of the Stripe API can be confident that Stripe won’t decide to break their applications, and they can upgrade versions at their own pace.
I don’t like API versioning. I think at best it’s a necessary evil, but it’s still evil. It’s confusing to users, who can’t easily search for your API docs without making sure that the version selector matches the version they’re using. And it’s a nightmare for maintainers. If you have thirty API endpoints, every new version you add introduces thirty new endpoints to maintain. You will rapidly end up with hundreds of APIs that all need testing, debugging, and customer support.
Of course, adding a new version doesn’t double the size of your codebase. Any sensible API versioning backend will have something like a translation layer that can turn a response into any of your public API versions. Stripe has something like this: the actual business logic is the same for all versions, so only the parameter serializing and deserializing needs to be aware of versioning. However, abstractions like that will always leak. See this 2017 HN comment from a Stripe employee, pointing out that some versioning changes need conditional logic throughout the “core code”.
In short, you should only use API versioning as a last resort.
An API by itself doesn’t do anything. It’s a layer between the user and the thing they actually want. For the OpenAI API, that’s the ability to do inference with a language model. For the Twilio API, that’s sending SMS messages. Nobody uses an API because the API itself is so elegantly designed. They use it to interact with your product. If your product is valuable enough, users will flock to even a terrible API.
This is why some of the most popular APIs are a nightmare to use. Facebook and Jira are famous for having appalling APIs, but it doesn’t matter - if you want to integrate with Facebook or Jira, which you do, you need to spend the time to figure them out. Sure, it would be nice if those companies had a better API. But why invest the time and money into improving it when people are going to integrate with it anyway? Writing good APIs is really hard.
I’m going to give a lot of concrete advice in the rest of this post about how to write good APIs. But it’s worth remembering that most of the time it doesn’t matter. If your product is desirable, any barely-functional API will do; if it isn’t, it doesn’t matter how good your API is. API quality is a marginal feature: it only matters when a consumer is choosing between two basically-equivalent products.
Incidentally, the presence of an API is an entirely different story. If one product doesn’t have an API at all, that’s a big problem. Technical users will demand some way to integrate with the software they’re buying via code.
A technically-great API can’t save a product that nobody wants to use. However, a technically-poor product can make it nearly impossible to build an elegant API. That’s because API design usually tracks the “basic resources” of a product (for instance, Jira’s resources would be issues, projects, users and so on). When those resources are set up awkwardly, that makes the API awkward as well.
As an example, consider a blogging system that stored comments in-memory as a linked list (each comment has a next field that points to the next comment in the thread). This is a terrible way to store comments. The naive way to bolt a REST API onto this system would be to have an interface that looks like this:
GET /comments/1 -> { id: 1, body: "...", next_comment_id: 2 }
Or even worse, like this:
GET /comments -> {body: "...", next_comment: { body: "...", next_comment: {...}}}
This might seem like a silly example, because in practice you’d just iterate over the linked list and return an array of comments in the API response. But even if you’re willing to do that extra work, how far down do you iterate? In a thread with thousands of comments, is it just impossible to fetch any comment after the first few hundred? Will your comment-fetching API have to use a background job, forcing the interface to turn into something like:
POST /comments/fetch_job/1 -> { job_id: 589 }
GET /comments_job/589 -> { status: 'complete', comments: [...] }
This is how some of the worst APIs happen. Technical constraints that can be cleverly hidden in the UI are laid bare in the API, forcing API consumers to understand far more of the system design than they should reasonably have to.
You should let people use your APIs with a long-lived API key. Yes, API keys are not as secure as various forms of short-lived credentials, like OAuth (which you should probably also support). It doesn’t matter. Every integration with your API begins life as a simple script, and using an API key is the easiest way to get a simple script working. You want to make it as easy as possible for engineers to get started.
Although consumers of an API will (almost by definition) be writing code, many of your users will not be professional engineers. They may be salespeople, product managers, students, hobbyists, and so on. When you’re an engineer at a tech company building an API, it’s easy to imagine that you’re building it for other people like yourself: full-time, competent, professional software engineers. But you’re not. You’re building it for a very wide cross-section of people, many of whom are not comfortable writing or reading code. If your API requires users to do anything difficult - like performing an OAuth handshake - many of those users will struggle.
When an API request succeeds, you know it did what it tried to do. What about when it fails? Some types of failure tell you what happened: a 422 typically means it failed during the request-validation stage, before any action was taken3. But what about a 500? What about a timeout?
This is important for API operations that take action. If you’re hitting some Jira API to create an issue comment, and the request 500s or times out, should you retry? You don’t know for sure whether the comment has been created or not, since the error might be happening after that operation. If you retry, you might end up posting two comments. This is even more important when there’s more at stake than a Jira comment. What if you’re transferring some amount of money? What if you’re dispensing medication?
The solution is idempotency, which is a fancy word for “the request should be safely retriable without creating duplicates”. The standard way to do this is to support an “idempotency key” in the request (say, some user-defined string in a parameter or header). When the API server gets a “create comment” request with an idempotency key, it first looks to see if it’s seen this idempotency key before. If so, it does nothing; otherwise it goes and creates the comment, then saves the idempotency key. That way you can send as many retries as you like, as long as they’ve all got the same idempotency key - the operation will only be performed once.
How should you store the key? I’ve seen people store it in some durable, resource-specific way (e.g. as a column on the comments table), but I don’t think that’s strictly necessary. The easiest way is to put them in Redis or some similar key/value store (with the idempotency key as the key). UUIDs are unique enough that you don’t need to scope them by user, but you may as well. If you’re not dealing with payments, you can even expire them after a few hours, since most retries happen immediately.
Do you need idempotency keys for every request? Well, you don’t need them for read requests, since double-reads are harmless. You also typically4 don’t need them for delete requests, since if you’re deleting by resource ID, that ID serves as the idempotency key. Think about it - if you send three DELETE comments/32 requests in a row, it won’t delete three comments. The first successful request will delete the comment with ID 32, and the remaining requests will 404 when they can’t find the already-deleted comment.
For most cases, idempotency should be optional. Like I said above, you want to make sure that your API is accessible to non-engineers (who often find idempotency a tricky concept). In the grand scheme of things, getting more people on your API is more important than the occasional duplicated comment from users who didn’t read the documentation.
Users who are interacting with your UI are limited by the speed of their hands. If there’s some flow that’s expensive for your backend to serve, a malicious or careless user can only trigger that flow as fast as they can click through it. APIs are different. Any operation you expose via an API can be called at the speed of code.
Be careful about APIs that do a lot of work in a single request. When I worked at Zendesk, we had an API that let you fan out notifications to all the users of a particular app. Some enterprising third-party developer5 used this to build an in-app chat system, where every message sent a notification to every other user on the account. For accounts with more than a handful of active users, this reliably killed the Apps backend server.
We didn’t anticipate people building a chat app on top of this API. But once it was out there, people did what they wanted with it. I’ve been in many, many incident calls where the root cause was some hand-rolled customer integration that was doing something silly, like:
/index endpoint with no delay in between, foreverYou should put a rate limit on your API, with tighter limits for expensive operations. It’s also sensible to reserve the ability to temporarily disable the API for specific customers, so you can take the pressure off your backend system if it’s really getting hammered.
Include rate limiting metadata in your API responses. X-Limit-Remaining and Retry-After headers give clients the information they need to be respectful consumers of your API, and allow you to set stricter rate limits than you would otherwise be able to.
Almost every API has to serve a long list of records. Sometimes a very long list (for instance, the Zendesk /tickets API can contain millions of tickets). How can you serve those records?
A naive SELECT * FROM tickets WHERE... approach will blow out your available memory (if not in the database, then in the application layer where you’re trying to serialize this million-item list). You just can’t serve every ticket in a single request. Instead, you have to paginate.
The simplest way to paginate is to use pages (or “offsets”, more generically). When you hit /tickets, you get the first ten tickets on the account. To get more, you have to hit either /tickets?page=2 or /tickets?offset=20. This is easy to implement, since the server can just add OFFSET 20 LIMIT 10 to the end of the database query. But it doesn’t scale to really high numbers of records. Relational databases have to count through your offset every time, so each page you serve gets a little slower than the last page. By the time your offset is in the hundreds of thousands, it’s a real problem.
The solution is “cursor-based pagination”. Instead of passing offset=20 to get the second page, you take the final ticket on the first page (say, with ID 32) and pass cursor=32. The API will then return the next ten tickets, starting with ticket number 32. Instead of using OFFSET, the query becomes WHERE id > cursor ORDER BY id LIMIT 10. That query is equally quick whether you’re at the start of the collection or hundreds of thousands of tickets in, because the database can instantly find the (indexed) position of your cursor ticket instead of having to count through the entire offset.
You should always use cursor-based pagination for datasets that might end up being large. Even though it’s harder for consumers to grasp, when you run into scaling problems you might have to change to cursor-based pagination anyway, and the cost of making that change is often very high. However, I think it’s fine to use page or offset-based pagination otherwise.
It’s usually wise to include a next_page field in your API list responses. That saves consumers having to figure out the next page number or cursor on their own.
If parts of your API response are expensive to serve, make them optional. For instance, if fetching the user’s subscription status requires your backend to make an API call, consider making your /users/:id endpoint not return subscription unless the request passes an include_subscription parameter. As a more general approach, you could have an includes array parameter with all your optional fields. This is often used for records that are associated (for instance, you could pass includes: [posts] to your user request to get the user’s posts in the response).
This is part of the idea behind GraphQL, a style of API where instead of hitting different endpoints per-operation, you craft a single query with all the data you need and the backend figures it out6.
I don’t like GraphQL very much, for three reasons. First, it’s completely impenetrable to non-engineers (and to many engineers). Once you learn it, it’s a tool like any other, but the barrier to entry is just so much higher than GET /users/1. Second, I don’t like giving users the freedom to craft arbitrary queries. It makes caching more complicated and increases the number of edge cases you have to think about. Third, in my experience the backend implementation is so much more fiddly than your standard REST API.
I don’t feel that strongly about my dislike of GraphQL. I’ve spent maybe six months working with it in various contexts and am far from an expert. I’m sure there are use cases where it buys you enough flexibility to be worth the costs. But right now I’d only use it where I absolutely had to.
Everything I’ve said so far is about public APIs. What about internal APIs: APIs that are only used by your colleagues at a particular company? Some of the assumptions I’ve made above don’t hold for internal APIs. For instance, your consumers are usually professional software engineers. It’s also possible to safely make breaking changes, because (a) you often have an order of magnitude fewer users, and (b) you have the ability to go in and ship new code for all of those users. You can require as complex a form of authentication as you want.
However, internal APIs can still be a source of incidents, and still need to be idempotent for key operations.
What haven’t I written about? I haven’t written much about REST vs SOAP, or JSON vs XML, because I don’t think that stuff is particularly important. I like REST and JSON, but I don’t feel strongly about it. I also haven’t mentioned OpenAPI schema - it’s a useful tool, but I think it’s also fine to just write your API docs in Markdown if you want.
The reminder to "never break userspace" is good, but people never bring up the other half of that statement: "we can and will break kernel APIs without warning".
It illustrates that the reminder isn't "never change an API in a way that breaks someone", it's the more nuanced "declare what's stable, and never break those".
Even if the kernel doesn't break userspace, GNU libc does, all the time, so the net effect is that Linux userspace is broken regardless of the kernel maintainers' efforts. Put simply, programs and libraries compiled on/for newer libc are ABI-incompatible or straight-up do not run on older libc, so everything needs to be upgraded in lockstep.
It is a bit ironic and a little funny that Windows solved this problem a couple decades ago with redistributables.
GNU libc has pretty good backwards compatibility, though, so if not you want to run on a broad range of versions, link against as old a version of libc as is practical (which does take some effort, annoyingly). It tends to be things like GUI libraries and such which are a bigger PITA, because they do break compatibility and the old versions stop being shipped in distros, and shipping them all with your app can still run into protocol compatibility issues.
You're describing 2 completely different things there.
If your program is built to require myfavoritelibrary version 1.9, and you try to run it against myfavoritelibrary 1.0, no shit it doesn't work. Glibc is no different than any other in this regard.
If your program is built to require myfavoritelibrary version 1.0, and you try to run it on myfavoritelibrary 1.9 ... glibc's binary compatibility story has been very good since the release of 2.2 or so, way back in 2000. (I know from documentation that there were a lot of 2.0 -> 2.1 breakages, some of which might've actually been fixed in 2.1.x point releases, so I'm saying 2.2 to be safe)
It's not quite as perfect as Linux's "we do not break userland" but it's pretty darn close; I would have to hunt down changelogs to find something that actually broke without explicitly relying on "do not rely on this" APIs. Source compatibility is a different story, since deprecated APIs can be removed from the public headers but still present in the binary.
... actually, even Linux has unapologetically broken its promise pretty badly in the past at various times. The 2.4 to 2.6 transition in particular was nasty. I'm also aware of at least one common syscall that broke in a very nasty way in some early versions; you can't just use ENOSYS to detect it but have to set up extra registers in a particular way to induce failure for incompatible versions (but only on some architectures; good luck with your testing!)
---
There's nothing stopping you from installing and using the latest glibc and libgcc at runtime, though you'll have to work around your distro's happy path. Just be careful if you're building against them since you probably don't want to add extra dependencies for everything you build.
By contrast, I have statically-linked binaries from ~2006 that simply do not work anymore, because something in the filesystem has changed and their version of libc can't be fixed the way the dynamically-linked version has.
You can find the history of API/ABI changes in glibc since 2011 in this table:
https://abi-laboratory.pro/?view=timeline&l=glibc
Granted it hasn't been updated since 2023, you can still see the trend with removed symbols in each version.
I looked into the changelog for 2.34, which this website claims removed 24 symbols.
* 9 malloc debugging variables were removed, though their symbols actually remain for backwards compatibility they just don't do anything. * vtimes was removed, but the symbol remains for backwards compatibility
Those were the only changelog entries listing removals. None of them cause linking issues. The 9 that did break backwards compatibility are a set of debug tools that don't work for alternate memory allocators and the functionality can be brought back with libc_malloc_debug.so.
Maybe the changelog's incomplete, but this actually seem pretty tame to me.
The nastiest removal I'm aware of is `crypt`, and even in that case it's just a matter of adding the appropriate `LD_PRELOAD` if you can't relink.
> glibc's binary compatibility story has been very good since the release of 2.2 or so, way back in 2000
It has been better than most but they recently broke loading libraries that declared they need an executable stack (even if the library never used it) and there doesn't seem to be a plan to actually fix the backwards compatibility issue.
otoh staticly-linked executables are incredibly stable - it's nice to have that option.
From what I understand, statically linking in GNU's libc.a without releasing source code is a violation of LGPL. Which would break maybe 95% of companies out there running proprietary software on Linux.
musl libc has a more permissive licence, but I hear it performs worse than GNU libc. One can hope for LLVM libc[1] so the entire toolchain would become Clang/LLVM, from the compiler driver to the C/C++ standard libraries. And then it'd be nice to whole-program-optimise from user code all the way to the libc implementation, rip through dead code, and collapse binary sizes.
AFAIK, it's technically legal under the LGPL to statically link glibc as long as you also include a copy of the application's object code, along with instructions for how users can re-link against a different glibc if they wish. You don't need to include the source for those .o files.
But I don't think I've ever seen anybody actually do this.
Musl is probably the better choice for static linking anyway, GNU libc relies on dynamic linking for a few important features.
The Windows redistributables are so annoying as a user. I remember countless times applications used to ask me to visit the official Microsoft page for downloading them, and it was quite hard to find the right buttons to press to get the thing. Felt like offloading the burden to the users.
Many installers do it right and don't require the user to do it themselves.
GNU LibC is notoriously difficult to statically link to anyway. (getaddrinfo for example).
Most people use musl, though some others use uclibc.
Musl is actually great, even if it comes with some performance drawbacks in a few cases.
You can (equivalently) distribute some specific libc.so with your application. I don't think anyone other than GNU maximalists believes this infects your application with the (L)GPL.
You'd need to distribute ld.so also, otherwise you'll run into ld/libc incompatibilities.
"GNU maximalist" is an odd choice of wording, since it would seem to imply people who are the most well-informed about the project's licenses, but anyone who thinks that distributing an LGPL library without your own app's corresponding source code is someone who flat out doesn't understand the LGPL.
A mistake in my own wording crept in here; this should have said:
> one who thinks that distributing an LGPL library without your own app's corresponding source code is forbidden is someone who flat out doesn't understand the LGPL
Yeah, famously there is no stable public driver API for Linux, which I believe was the motivation for Google’s Fuschia OS
So Linux is opinionated in both directions - towards user space and toward hardware - but in the opposite way
It's not just opinionation though. It's the kernel's leverage against the people who would keep their modules out of the git tree instead of contributing them. Those people literally get left behind and they are forced to pay maintainers to chase a continuously moving target. The solution to that is to contribute the code.
In software engineering the statement "interfaces, not implementations" has been used for a long time (certainly at least Robert "Uncle Bob" C. Martin started teaching), which is a generalization on the "we don't break userspace". In essence it cooks down to declaring an interface without announcing or depending on the implementation. With OOP languages like C++, a code base would aggressively use interfaces as types, never concrete class types (which implement the interface), so that it can make it easier to reason about how and whether the program behaves when one implementation of an interface is swapped for another.
With Linux, which is a C codebase by and large, they load and pass pointers to structures to kernel procedures which can do as they please -- as long as the documentation on said structures (which usually says which fields and how are retained with which values and so on) remains unchanged. That's their "object oriented programming" (yeah, I know Linus would likely have hated the comparison).
makes me remember a Evan: we provided a migration path from 2 to 3, but so many internal changed that many plugins broke
While the author doesn't seem to like version based APIs very much, I always recommend baking them in from the very start of your application.
You cannot predict the future and chances are there will be some breaking change forced upon you by someone or something out of your control.
I have to agree with the author about not adding "v1" since it's rarely useful.
What actually happens as the API grows-
First, the team extends the existing endpoints as much as possible, adding new fields/options without breaking compatibility.
Then, once they need to have backwards-incompatible operations, it's more likely that they will also want to revisit the endpoint naming too, so they'll just create new endpoints with new names. (instead of naming anything "v2").
Then, if the entire API needs to be reworked, it's more likely that the team will just decide to deprecate the entire service/API, and then launch a new and better service with a different name to replace it.
So in the end, it's really rare that any endpoints ever have "/v2" in the name. I've been in the industry 25 years and only once have I seen a service that had a "/v2" to go with its "/v1".
> So in the end, it's really rare that any endpoints ever have "/v2" in the name.
This is an interesting empirical question - take the 100 most used HTTP APIs and see what they do for backward-incompatible changes and see what versions are available. Maybe an LLM could figure this out.
I've been just using the Dropbox API and it is, sure enough, on "v2". (although they save you a character in the URL by prefixing "/2/").
Interesting to see some of the choices in v1->v2,
https://www.dropbox.com/developers/reference/migration-guide
They use a spec language they developed called stone (https://github.com/dropbox/stone).
The author does not say that you “should not add v1”. They say that versioning is how you change your API responsibly (so, endorsing versioning), but that you should only do it as a last resort.
So you would add “v1”, to be able to easily bump to v2 later if needed, and do your best to avoid bumping to v2 if at all possible.
I don't see any harm in adding versioning later. Let's say your api is /api/posts, then the next version is simply /api/v2/posts.
It's a problem downstream. Integrators weren't forced to include a version number for v1, so the rework overhead to use v2 will be higher than if it was present in your scheme to begin.
This here, it's way easier to grep a file for /v1/ and show all the api endpoints then ensure you haven't missed something.
Switching to an incompatible/breaking API requires some rework anyway. The consumer of an API usually doesn't support multiple versions in parallel.
I don't think the author meant they don't include /v1 in the endpoint in the beginning. The point is that you should do everything to avoid having a /v2, because you would have to maintain two versions for every bug fix, which means making the same code change in two places or having extra conditional logic multiplied against any existing or new conditional logic. The code bases that support multiple versions look like spaghetti code, and it usually means that /v1 was not designed with future compatibility in mind.
If you really care about maintaining v1 long-term you'd re-implement it as a small shim above v2.
> While the author doesn't seem to like version based APIs very much, I always recommend baking them in from the very start of your application.
You don’t really need to do that for REST APIs. If clients request application/vnd.foobar then you can always add application/vnd.foo.bar;version=2 later without planning this in advance.
If you use something like an OpenAPI generator and want to have different DTOs in your version 2, then you cannot do what you suggested.
I've been using OpenAPI for years with multiple versioning types (header based, content negotiation + media type based) and haven't had issues across Java, Typescript or Go with generating the right code for it
You can specify multiple media types in OpenAPI.
Most REST APIs don’t support that. So you don’t need versioning for APIs that already have a request type specified.
I’m not sure what you mean in the context of a discussion about how to design APIs. If you are the one designing an API, it’s up to you what you support.
We call that baking them in from the very start of your application, which is what you claimed this didn’t need.
You don’t need to bake it in from the start.
Actually, there’s nothing stopping you from using a custom "Version: 2" Header in requests and responses
The problem is with parameters (or headers) you are still stuck with same API schema (you cannot rename it, etc).
But thanks to versions, in my job we renamed old APIs like /v1/oauthApple and /v1/oauthGoogle to /v2/login/oauth/apple and /v2/login/oauth/google, looks so much better.
> The problem is with parameters (or headers) you are still stuck with same API schema (you cannot rename it, etc).
That doesn’t make sense. The whole point of creating a new version is to change the schema. And what do you mean “rename it”?
> But thanks to versions, in my job we renamed old APIs like /v1/oauthApple and /v1/oauthGoogle to /v2/login/oauth/apple and /v2/login/oauth/google, looks so much better.
Wait, by schema do you mean URL structure?
You’re looking at this backwards. The benefit of using headers is that you can keep the same URL. In a REST API, the URL is the primary key. If Client A holds a copy of /v1/foo/1 and Client B holds a copy of /v2/foo/1 then as far as HTTP and REST are concerned, those are two different resources and the clients cannot interoperate. If Client A holds a copy of /foo/1 in application/vnd.foo;version=1 format and Client B holds a copy of /foo/1 in application/vnd.foo;version=2 format, then those clients have the same resource and can interoperate.
But if you want to change your URL structure, you can do that too. There’s nothing stopping you from moving /oauthApple to /oauth/apple, you don’t even need a new version to do that – just change the link.
Disagree. Baking versioning in from the start means they will much more likely be used, which is a bad thing.
I would say the author recommends the same actually: they say that versioning is “how you change your API responsibly” (so, endorsing versioning), but that you should only switch to a new version as a last resort.
If there is a breaking change forced upon in the future, can’t we use a different name for the function?
A versioned API allows for you to ensure a given version has one way to do things and not 5, 4 of which are no longer supported but can't be removed. You can drop old weight without messing up legacy systems.
See the many "Ex" variations of many functions in the Win32 API for examples of exactly that!
/api/postsFinalFinalV2Copy1-2025(1)ExtraFixed
Discoverability.
/v1/downloadFile
/v2/downloadFile
Is much easier to check for a v3 then
/api/downloadFile
/api/downloadFileOver2gb
/api/downloadSignedFile
Etc. Etc.
I have only twice seen a service ever make a /v2.
It's typically to declare bankruptcy on the entirety of /v1 and force eventual migration of everyone onto /v2 (if that's even possible).
A lot of the Unix/Linux Syscall api has a version 2+
For example dup(), dup2(), dup3() and pipe(), pipe2() etc
LWN has an article: https://lwn.net/Articles/585415/
It talks about avoiding this by designing future APIs using a flags bitmask to allow API to be extended in future.
I work for a company that has an older api so it's defined in the header, but we're up to v6 at this point. Very useful for changes that have happened over the years.
Isn’t having the name (e.g. Over2gb) easier to understand than just saying v2? This is in the situation where there is breaking changes forced upon v1/downloadFile.
If you only break one or two functions, it seems ok. But, some change in a core data type could break everything, so adding a prefix "/v2/" would probably be cleaner.
You could, but it just radically increases complexity in comparison to "version" knob in a URI, media type, or header.
Cursor based pagination was mentioned. It has another useful feature: If items have been added between when a user loads the page and hits the next button, index based pagination will give you some already viewed items from the previous page.
Cursor based pagination (using the ID of the last object on the previous page) will give you a new list of items that haven't been viewed. This is helpful for infinite scrolling.
The downside to cursor based pagination is that it's hard to build a jump to page N button.
You should make your cursors opaque so as to never reveal the size of your database.
You can do some other cool stuff if they're opaque - encode additional state within the cursor itself: search parameters, warm cache / routing topology, etc.
Came here to say these same things exactly. Best write up I know on this subject: https://use-the-index-luke.com/sql/partial-results/fetch-nex...