Minecraft no longer counts in 1.x. The latest version is called 26.2 and it shipped on June 16, 2026. If you maintain a plugin written for 1.21, the interesting question is not only technical, it is strategic. Migrating costs time, and targeting the wrong version costs installs. This guide covers exactly what to change in the code, in which order, and above all which version to aim at, because the right answer is not the newest one.
What calendar versioning actually changed
The version number now encodes the year and the release rank inside that year. 26.2 means the second release of 2026, nothing more. The number no longer tells you whether a change is cosmetic or structural, and that is the first habit to unlearn: you cannot size a migration from the version jump any more. You have to look at what moved underneath.
Underneath, three things moved and all three matter. Minecraft has been deobfuscated since 26.1, which made Paper's remapper disappear. Paper 26.x ships Adventure 5, which removed APIs that had previously only been deprecated. And Java 25 became the runtime floor for calendar versions. A 1.21 plugin that touches any of those three areas does not simply recompile.
The number almost nobody checks before migrating
Which version to actually target
There is one more trap. Paper 26.2 has no stable build: the sensible production target on the calendar branch is 26.1.2, not 26.2. Shipping a plugin that only exists for 26.2 means asking your users to run a base that Paper itself does not call stable.
| Share of servers | Verdict | |
|---|---|---|
| 1.21.11 | 36.4% | Primary target, most of your installs live here |
| 26.1.2 | 14.9% | Calendar production target, the one to test on |
| 26.2 | 7.4% | No stable Paper build, do not ship for it alone |
| 1.21.4 | 7.2% | Free compatibility if you stay on the public API |
My reading, stated plainly: dropping 1.21 today would be a mistake. That is not nostalgia, it is arithmetic. You would give up the majority family to gain a branch that weighs half as much, whose newest release does not even have a stable build. The right target is a single plugin that compiles against 1.21, loads on 26.x, and uses nothing that vanished between the two.
"You do not migrate to the latest version, you migrate to the version the people installing your plugin are actually running."
API version and Java version
Two separate settings that get confused constantly. The API version is the generation of the Bukkit API your code is written against, declared in plugin.yml. The Java version is the bytecode format your compiler emits. They are set independently, and getting the second one wrong is the most mundane cause of a plugin refusing to load.
<properties>
<!-- 21, not 25: Java 21 bytecode loads on a newer runtime.
The reverse is not true. -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.11-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
</dependencies>
Compile against the oldest version you intend to support, not the newest. A plugin compiled against the 26.x API and installed on a 1.21 server dies the moment it calls a method that does not exist there yet. The other way round, a plugin compiled against 1.21 runs on 26.x as long as it avoids removed APIs. That asymmetry is your friend. Exact coordinates per branch are on docs.papermc.io, and the signatures are on jd.papermc.io.
Java 25 is a runtime floor, not a compile target
UnsupportedClassVersionError to show for it. Stay on 21 unless you genuinely need a specific language feature, which is rare inside a plugin.Adventure 5: text is the blast radius
This is where most of the damage happens. Paper 26.x ships Adventure 5, and Adventure 5 removed APIs that until then were only deprecated. The affected surface is predictable: everything to do with messages and text displayed to players. A 1.21 plugin that sends messages the old way may simply stop compiling.
The good news is that there is one exit and it works in both worlds. Adventure has been included in Paper for a long time, so code written with Component compiles on 1.21 and on 26.x alike. You are not maintaining two versions, you are modernising once.
package fun.example.migration;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
public final class Messages {
private static final MiniMessage MINI = MiniMessage.miniMessage();
private Messages() {
}
/** Built in code, with no colour strings anywhere. */
public static Component welcome(String playerName) {
return Component.text("Welcome ", NamedTextColor.GOLD)
.append(Component.text(playerName, NamedTextColor.WHITE))
.append(Component.text(" to the server.", NamedTextColor.GRAY));
}
/** Same idea for a number, without coloured concatenation. */
public static Component balance(long amount) {
return Component.text("Balance: ", NamedTextColor.GRAY)
.append(Component.text(amount, NamedTextColor.GOLD));
}
/** A line the admin wrote in config.yml, in MiniMessage format. */
public static Component fromConfig(String line) {
return MINI.deserialize(line);
}
/**
* A legacy string inherited from an older config.yml, such as "&aGreen text".
* Convert it instead of dropping it: those files already exist on real
* servers and nobody is going to rewrite them by hand.
*/
public static Component fromLegacy(String stored) {
return LegacyComponentSerializer.legacyAmpersand().deserialize(stored);
}
}
At the call site nothing clever is needed: player.sendMessage(Messages.welcome(player.getName())). Adventure documentation lives on docs.advntr.dev, and the Paper side of the integration on docs.papermc.io.
What does not work: find and replace
config.yml, and every server that had customised its messages loses them on first boot. Two: you miss the places that still compile but change appearance in game, such as item names, inventory titles and scoreboard lines. Inventory those three surfaces by hand before you touch the code.The remapper is gone
Minecraft has been deobfuscated since 26.1, and Paper's remapper went away with the obfuscation. If your plugin stayed politely on the public Bukkit and Paper API, you will notice nothing at all. If it reached down into the server's internal classes, this part hits you head on: the remapping step your build relied on no longer exists, and the names you were reflecting over are no longer the same names.
My advice here is blunt and I stand by it: use the migration as the excuse to get out of internals. Reflection into internal classes is exactly the kind of code that demands a fix every single version, calendar or not. Most of the time the public API now does the same job. When it genuinely does not, isolate that access in one class with a clean fallback if the expected class is missing, instead of scattering it through the plugin.
What deobfuscation gives you
No remapping step in the build any more
Readable stack traces in crash reports
Readable internals when you need to understand a behaviour
What it breaks
Builds that depended on the remapper stop working
Reflection on old obfuscated names fails
Every NMS tutorial written before 26.1 is stale
The libraries block instead of shading
The historical reflex was to bundle your dependencies inside the .jar, in other words to shade them. The libraries: block in plugin.yml replaces that practice: you declare the dependency and the server downloads it itself. The .jar goes back to being small and containing only your code.
Before you declare anything, check what is already there. The SQLite JDBC driver already ships with Paper, so if you were bundling it, you were paying weight for nothing. HikariCP, on the other hand, is not shipped. That is exactly the kind of dependency libraries: exists for.
name: MyPlugin
version: 1.0.0
main: fun.example.migration.MyPlugin
api-version: '1.21'
description: Migrated plugin, one .jar for 1.21 and 26.x
authors: [ MyName ]
# Downloaded by the server on first startup.
# The SQLite driver is NOT here: Paper already provides it.
libraries:
- com.zaxxer:HikariCP:5.1.0
commands:
balance:
description: Show your balance
usage: /balance
The api-version value declares the API generation your code is written against. Keep it on the oldest generation you truly support: that is what keeps you loadable across the whole 1.21 family while still being accepted by newer servers. The file format is documented on docs.papermc.io and, for the historical Bukkit side, on hub.spigotmc.org.
What does not work: assuming libraries is free
The offline-mode UUID trap
Here is the part almost no migration guide covers, and it is the one that destroys the most data. 75.1% of servers run in offline mode. In offline mode a player's UUID is derived from their username. That is not an implementation detail, it is a property that changes how you design your database: if the player renames, or if the server ever switches to online mode, the key changes and every row stored under the old UUID is orphaned.
Concretely, an economy plugin that stores a balance against a UUID and nothing else hands its users this scenario: the renamed player loses their money, the admin has no idea what happened, and nobody can reconstruct the mapping. The fix is one column: keep the username next to the UUID, index it, and you keep a path back.
CREATE TABLE IF NOT EXISTS player_data (
uuid TEXT PRIMARY KEY,
last_name TEXT NOT NULL,
balance INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
-- Mandatory in offline mode: it is the only route back when a player
-- renames, or when the server flips to online mode.
CREATE INDEX IF NOT EXISTS idx_player_data_name ON player_data(last_name);
package fun.example.migration;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
public final class PlayerRepository {
private final Connection connection;
public PlayerRepository(Connection connection) {
this.connection = connection;
}
/**
* Always write the current username alongside the data.
* Without that line, a rename makes the row unreachable on the
* 75.1% of servers running in offline mode.
*/
public void save(UUID uuid, String name, long balance) throws SQLException {
String sql = "INSERT INTO player_data(uuid, last_name, balance, updated_at) "
+ "VALUES(?, ?, ?, ?) "
+ "ON CONFLICT(uuid) DO UPDATE SET "
+ "last_name = excluded.last_name, "
+ "balance = excluded.balance, "
+ "updated_at = excluded.updated_at";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, uuid.toString());
ps.setString(2, name);
ps.setLong(3, balance);
ps.setLong(4, System.currentTimeMillis());
ps.executeUpdate();
}
}
public long readBalance(UUID uuid) throws SQLException {
String sql = "SELECT balance FROM player_data WHERE uuid = ?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getLong(1) : 0L;
}
}
}
}
The test I run every time
Folia: the main thread is no longer a given
Folia runs world regions across several threads. A plugin that assumes a single main thread does not get slower, it crashes. The migration means going through the RegionScheduler to touch the world, and never running database access on a game thread, or your TPS pays for it.
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import java.sql.SQLException;
import java.util.UUID;
public final class BalanceLookup {
private final Plugin plugin;
private final PlayerRepository repository;
public BalanceLookup(Plugin plugin, PlayerRepository repository) {
this.plugin = plugin;
this.repository = repository;
}
public void showBalance(UUID uuid, Location location) {
// 1. The database, off every game thread.
Bukkit.getAsyncScheduler().runNow(plugin, task -> {
final long balance;
try {
balance = repository.readBalance(uuid);
} catch (SQLException e) {
plugin.getLogger().warning("Could not read balance: " + e.getMessage());
return;
}
// 2. Back to the world, on the thread that owns this region.
Bukkit.getRegionScheduler().execute(plugin, location, () -> {
Player player = Bukkit.getPlayer(uuid);
if (player != null) {
player.sendMessage(Messages.balance(balance));
}
});
});
}
}
This code is not Folia only: Paper exposes those schedulers, so you can write it today against your 1.21 target and stay compatible without a second codebase. Folia behaviour is documented on docs.papermc.io, and if you want to see the shape of the result without setting up the environment, our Folia plugin generator emits RegionScheduler code directly.
My call, stated plainly
A neutral overview helps nobody, so here is what I recommend.
One .jar for the vast majority of cases. Compiled for Java 21, against the 1.21 API, zero internal access, Adventure everywhere, Folia-compatible schedulers. That plugin covers the majority 1.21 family and loads on 26.x. Two separate builds are only justified if you genuinely touch the server's internal classes.
Folia: on a modest server, I do not migrate. The RegionScheduler makes the code harder to read for a gain you will not measure when a single region carries everyone. On a busy server, or on a very spread-out map where players are scattered, the investment starts paying off. Write the compatible code from the start anyway: it costs nothing on Paper and saves you a rewrite the day your scale changes.
Database: SQLite for as long as you have a single server. The driver is already provided, one connection is enough, and you declare nothing at all. The day several servers must share the same data, you move to MySQL, and only then does HikariCP belong in the libraries: block. A connection pool over a local SQLite file is complexity for its own sake.
26.2: I ship nothing for it. While there is no stable Paper build, the calendar target is 26.1.2. Test on 26.2 out of curiosity if you like, but do not build your compatibility story on it.
Do the migration without reading every line yourself
Describe your plugin and the version you target. Minax writes Adventure-based code, compiles a clean .jar and lets you test it online before installing.
Migrate my pluginThe migration checklist, in order
- Fix the target: compile against 1.21, verify loading on 26.1.2.
- Check that
maven.compiler.releaseis 21, not 25. - List every place that produces text: messages, item names, inventory titles, scoreboards.
- Move those places to
Component, and add a conversion for legacy config.yml strings. - Hunt down every access to the server's internal classes, then delete it or isolate it in one class.
- Strip from the .jar anything Paper already provides, starting with the SQLite JDBC driver.
- Declare what is left in
libraries:instead of shading it. - Move every database access off the game threads.
- Replace single-main-thread assumptions with the RegionScheduler.
- Add the username column next to the UUID, then run the offline-mode rename test.
- Install the .jar on a real server, once on 1.21 and once on 26.1.2, before publishing.
If a step breaks, read the first error line and nothing else: our plugin troubleshooting guide covers precisely the messages this kind of migration produces.
Conclusion
Calendar versioning made the targeting question more visible, not simpler. The real migration work is four jobs: text through Adventure, getting out of internals, dependencies declared rather than bundled, and dropping the single-thread assumption. Everything else is configuration. And the most important decision is not technical: while 1.21 is worth nearly twice the calendar population, the version to target is still 1.21, with code that also runs on 26.x. For what changed on the version side, our Minecraft 26 plugins page details the calendar branch.


