Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tags

Verified against Minecraft 26.3 · Part II · A parrot flies down through the canopy looking for a log to perch on, a moment after a data pack put a new block in the logs tag.

A parrot is looking for somewhere to sit. Parrot.ParrotWanderGoal scans the blocks around it and asks the block state beneath each one whether it is leaves or in BlockTags.LOGS — a leaves block it recognises by class, a log only by tag. Nothing in that goal names oak, or spruce, or the block a data pack added an hour ago; the tutorial toast that tells a new player to punch a tree asks the same question. A tag is a named set of registry entries, defined in data-pack JSON and tested against by key, and it is how data reaches into hard-coded behaviour without the behaviour naming any specific block. The registries page ended on a promise: a frozen registry’s contents never change. Its tags do. Type /reload with a data pack that adds a block to logs and the parrot perches on it a moment later — with BuiltInRegistries.BLOCK frozen since before the title screen. Nothing is unfrozen to do it, and no lock is taken; the swap is safe for a reason that has nothing to do with either.

The cast

classwhat it decidesthread
TagKeythe name: a registry key and an Identifier, interned so equal keys are one objectany
TagFile · TagEntrythe on-disk shape: a list of entries, each an element or a reference to another tag, and the replace flag—
TagLoaderreads every pack’s copy of every file and resolves them in dependency order; what an id resolves to is its TagLoader.ElementLookupWorker at world load, Server on /reload
MappedRegistrythe tag half: MappedRegistry.frozenTags, one HolderSet.Named per freeze-time key, and MappedRegistry.allTags, the bound MappedRegistry.TagSetread from any; written before the freeze by the launching thread, which binds a static registry’s tags to empty, and by a data-pack registry’s load tasks, and after it by whichever thread is applying
Registry.PendingTagsa loaded table not yet installed, with a Registry.PendingTags.lookup that hands out the new table’s tags, bound only when it is appliedbuilt on a worker at world load, on the server thread for /reload, on the Render thread from the packet; applied on the owning thread
HolderSet.Namedone tag’s contents, bound at each reload that keeps the tag—
Holder.Referenceone element’s own Set of TagKeys — the thing a membership test reads—
TagNetworkSerializationthe wire form: tag id to a list of registry intsServer encodes, client decodes

A tag is a key and a file

In code a tag is a TagKey: a record of the registry’s ResourceKey and an Identifier, interned through a weak interner so that TagKey.create always returns the canonical instance and a membership test is a set lookup on a canonical key. The keys are declared once, in catalogues that hold no contents — BlockTags, ItemTags, EntityTypeTags, BiomeTags, FluidTags and their siblings, twenty-one files in net/minecraft/tags including DamageTypeTags, EnchantmentTags, StructureTags, PoiTypeTags, FeatureTags and TimelineTags. A key that a block and its item share is declared once as a BlockItemTagId in BlockItemTags and projected into the catalogues that use it: BlockTags.LOGS is the block half of BlockItemTags.LOGS.

On disk a tag is a TagFile: a list of TagEntry and a replace flag. Each entry is an element id or a #-prefixed reference to another tag, with a required flag that defaults to true. The file lives at data/<namespace>/tags/<registry path>/<name>.json — Registries.tagsDirPath builds that string in exactly one place, and the registry path is singular: tags/block, tags/item, tags/entity_type, tags/worldgen/biome. Vanilla’s own files are written by the data generator (TagsProvider, TagBuilder), which the running game never calls, and players reach tags through the #tag syntax of ResourceOrTagArgument and ResourceOrTagKeyArgument (Part XIII). ResourceSelectorArgument, beside them, is a glob over ids and takes no tag at all.

Between the two sits TagLoader, generic over what an id resolves to: a Holder for a registry, a CommandFunction for function tags. It has two instance steps. TagLoader.load reads every pack’s copy of every file into lists of TagLoader.EntryWithSource; TagLoader.build resolves them in dependency order, TagLoader.tryBuildTag doing one tag at a time through the TagLoader.ElementLookup the loader was built with. Its output, a map from each tag to its holders, is resolved but bound to nothing. Nothing owns the loader: it is called as static functions from WorldLoader, MinecraftServer.reloadResources, ReloadableServerRegistries, the registry load tasks and the client’s RegistryDataCollector, with no manager between them. Function tags are the exception — ServerFunctionLibrary is a real reload listener and runs its own TagLoader inside it. Everything in net/minecraft/tags ships in both jars.

Inside a MappedRegistry a tag is two things. MappedRegistry.frozenTags holds one canonical HolderSet.Named per key for the tags that existed when the registry froze, and MappedRegistry.allTags is a MappedRegistry.TagSet, the bound view, which starts out as MappedRegistry.TagSet.unbound, where every read throws. Beside the registry, each Holder.Reference carries its own Set of TagKeys, bound by Holder.Reference.bindTags; that set, not the registry, is what Holder.Reference.is reads.

The four moments tags are loaded

World load comes first, on the worker pool (anatomy). TagLoader.loadTagsForExistingRegistries runs before the reload listeners, over the RegistryLayer.STATIC layer, and produces a Registry.PendingTags for every static registry that has at least one tag that builds. The pending tables are made visible to worldgen and reloadable-registry loading through TagLoader.buildUpdatedLookups, and applied, one registry at a time, in ReloadableServerResources.updateComponentsAndStaticRegistryTags once every listener has finished. For the whole of a load the old tags are what Registry.getTags answers and the new table’s names are what the loading codecs see.

/reload is the same call on the server thread, handed the composite access of MinecraftServer.registries — so it re-reads and re-applies tags for the dynamic worldgen registries too, not only the static ones. Of the loading paths only the reloadable registries re-run — loot tables, recipes, advancements and the rest — and a worldgen registry keeps its elements and gets new tags.

The third is a data-pack registry, which loads its tags inside its own load task rather than through the mechanism above: ResourceManagerRegistryLoadTask reads them after its elements, with TagLoader.ElementLookup.fromGetters, and RegistryLoadTask.registerTags binds them under the registry’s write lock — before the freeze, so there is a lock here and no swap. (The reloadable registries take this path too — they load through the same load tasks — so a data pack’s loot-table tags bind like any other’s, and vanilla ships none.)

The fourth moment is the client’s. One ClientboundUpdateTagsPacket covering every static and every synced registry is sent by SynchronizeRegistriesTask after the registry data. In configuration the client merely buffers it — ClientConfigurationPacketListenerImpl.handleUpdateTags hands it to RegistryDataCollector.appendTags, and nothing resolves until configuration finishes. After a server /reload, PlayerList.reloadResources broadcasts the same packet into the play phase, and ClientPacketListener.handleUpdateTags handles that one at once.

From JSON to a parrot’s decision

sequenceDiagram
    participant WL as WorldLoader
    participant TL as TagLoader
    participant MR as MappedRegistry
    participant RSR as Reloadable<br/>ServerResources
    participant Parrot as Parrot

    Note over WL,MR: world load, on the worker pool
    WL->>TL: loadTagsForExistingRegistries over the STATIC layer
    TL->>TL: load: every pack's tags/block/logs.json, lowest first
    TL->>TL: build: DependencySorter orders the nested tags, then tryBuildTag
    TL->>MR: prepareTagReload: a Registry.PendingTags, which Registry.getTags cannot see yet
    Note over WL,RSR: worldgen and reloadable-registry codecs see the new table through Registry.PendingTags.lookup
    Note over MR,RSR: the thread driving the load, after the last reload listener has applied
    RSR->>MR: Registry.PendingTags.apply: bind, swap, rebind every holder
    Note over Parrot: a later server tick, the parrot's wander goal
    Parrot->>Parrot: state.is(BlockTags.LOGS) — a set lookup on the holder's own tags
    Note over MR,Parrot: no arrow to the registry: the check never reaches it

One tag, from the pack files to the check that uses it. The gap between MappedRegistry.prepareTagReload and Registry.PendingTags.apply is where the opening’s riddle lives, a table built that Registry.getTags cannot yet see, and the last note marks an absence: the parrot’s check touches no registry at all.

Every pack’s file, lowest first

TagLoader.load asks the resource system for resource stacks — a FileToIdConverter.json over the tag directory, listed with FileToIdConverter.listMatchingResourceStacks — so every copy of tags/block/logs.json across the enabled packs is visited in priority order and merged, and a replace in a higher pack discards what the lower packs contributed to that id. A pack whose file fails to parse is logged and skipped, never fatal.

Tags of tags resolve in order, and a tag with a hole is dropped whole

The vanilla logs file names no block at all: it is three tag references, logs_that_burn, crimson_stems and warped_stems, and logs_that_burn is in turn ten references, oak_logs among them, before oak_logs finally lists four blocks. TagLoader.build feeds every tag reference into a DependencySorter and resolves the leaves first. A tag with any failing entry — a missing required element as much as a missing required tag reference — is dropped whole, not loaded minus the entry, and is then absent from Registry.getTags, so neither the network payload nor a lookup will find it. An optional entry (required: false) resolves to nothing and the tag still builds: TagEntry.build answers not required on a miss, whichever lookup it was handed. What the two lookups differ on is where a required id is looked up, and the difference decides what a missing one costs. TagLoader.ElementLookup.fromFrozenRegistry, used for a registry that has already frozen, asks it either way: a name it does not hold is a miss, and the tag is dropped. TagLoader.ElementLookup.fromGetters, used by data-pack registries as they load, sends a required id through the registration lookup, which manufactures a placeholder Holder.Reference for a name nothing has registered (MappedRegistry.createRegistrationLookup), and an optional one through the immutable lookup, which does not. A data-pack registry has registered all its elements before it reads its tags, so a placeholder there is never bound: the registry’s freeze fails, with unbound values, and takes the whole load with it.

Prepared, then applied

This is the swap the opening promised, and it is three steps and no lock. MappedRegistry.prepareTagReload refuses a registry that is not frozen and builds the new table, reusing the freeze’s HolderSet.Named objects where it can; nothing is installed yet, and the Registry.PendingTags.lookup it hands back answers with the new table’s names, which is what the worldgen and reloadable-registry codecs are given while they load. Registry.PendingTags.apply is then three ordered steps: bind each HolderSet.Named, swap the MappedRegistry.TagSet, then rebind every holder’s tag set in MappedRegistry.refreshTagsInHolders. Each step replaces an immutable collection with one reference write, so a reader on another thread — worldgen on the worker pool during a /reload, the Render thread in singleplayer — sees a holder’s old tags or its new ones and never a half-built set; what it can see, for the length of the rebind, is some holders already new and others still old. Which thread applies depends on the occasion: at world load the Server thread does not exist yet, so the apply runs on whichever thread is driving the load — the launching thread on a dedicated server, through Util.blockUntilDone, and the Render thread on the client. Only a reload of a running server — /reload, or /datapack enable or /datapack disable — applies on the Server thread.

The client gets integers

TagNetworkSerialization.serializeTagsToNetwork walks RegistrySynchronization.networkSafeRegistries and writes each tag as a list of registry ids, dropping any registry whose payload came out empty. That set is every RegistryLayer.STATIC registry, unconditionally, concatenated with the synced dynamic ones — RegistrySynchronization.isNetworkable filters only the second group. Ids for dynamic registries are meaningful only once both sides have built the same registry in the same order, which is why SynchronizeRegistriesTask sends the registry data first. In the configuration phase that ordering is a send-order constraint rather than a handling one: the client buffers both packets and resolves everything at the end. The play-phase packet resolves immediately against the live registry access, and the client then rebuilds the creative-inventory search tree from the new tags.

Singleplayer skips only what it already has

On the play path ClientPacketListener.handleUpdateTags always prepares, and skips only the apply on a memory connection, because the integrated server’s apply already rebound the registries both halves share. In configuration the suppression is narrower still: only the non-networkable (static) registries’ tags are skipped, and the client still binds tags on its own copies of the remote dynamic registries, copies it then discards for the server’s own (identifiers and registries).

The check is a field read

BlockBehaviour.BlockStateBase is a TypedInstance; TypedInstance.is asks the type holder — for a block, Block.builtInRegistryHolder, the intrusive holder from identifiers-and-registries — and Holder.Reference.is is set-contains on an interned TagKey. No registry is consulted. Parrot (the perch search), TrunkPlacer (worldgen) and the client’s PunchTreeTutorialStepInstance all ask this way; FluidState, Entity and ItemStack go through the same interface.

The other way tags cross the wire

ClientboundUpdateTagsPacket is not the only one. ByteBufCodecs.holderSet encodes a HolderSet.Named as a marker plus the tag’s Identifier, and decodes it by looking the tag up in the receiving side’s registry — so any packet or data component carrying a tag-shaped HolderSet hard-fails on a client that does not have that tag. That, more than the id numbering, is why the tags packet must reach the client before play traffic does.

The same idea appears in data: TagKey.hashedCodec, HolderSetCodec and RegistryCodecs are what turn “#minecraft:logs” in an ordinary JSON field — a recipe ingredient, a loot condition, a placement predicate — into a HolderSet without any of those files being tag files.

Questions players ask

What if two tags reference each other? Both are dropped, and both are logged. DependencySorter.addDependencyIfNotCyclic drops the edge that would close the cycle, so an order exists and the sort does not hang — but whichever tag is built first asks for a tag that is not in the new table yet, gets nothing, and fails as a missing required reference; the second then asks for the first, which failed, and fails the same way. Two Couldn’t load tag lines, and neither tag exists.

What happens to a tag a /reload deleted? It keeps its old contents, and nothing tells you. The tag is absent from the pending map, so apply neither rebinds nor clears it: anything still holding that HolderSet.Named will iterate the old list, while HolderSet.Named.contains — which reads the holder’s refreshed tag set — answers false, and Registry.get for the key answers empty.

Is picking a random element from a tag deterministic? Yes, per pack stack. Duplicate entries collapse and file order is preserved — TagLoader.tryBuildTag collects into an insertion-ordered set — so iterating a tag, or picking from it with HolderSet.getRandomElement, gives the same sequence for the same packs.

Which tags does a client never hear about? Those of RegistryLayer.RELOADABLE-layer registries (loot tables, predicates) and of non-synced worldgen registries (features, structures) — and on the receiving side, ids the client’s own registry does not know are dropped from the payload silently.

For a 1.21-era reader. There is no TagManager and no reload listener for registry tags: loading is static functions on TagLoader, called from WorldLoader, MinecraftServer.reloadResources, ReloadableServerRegistries, the registry load tasks and the client’s RegistryDataCollector, and only function tags still go through a real listener. The directory is singular and has no plural fallback — tags/block, not tags/blocks — because Registries.tagsDirPath builds that string in one place and accepts nothing else.

Where to look

TagKey · BlockTags · TagFile · TagLoader · Registry.PendingTags · MappedRegistry (the tag half) · HolderSet.Named · ReloadableServerResources · TagNetworkSerialization · Parrot.ParrotWanderGoal


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.