Compatibility
Minecraft: Java Edition
Platforms
Supported environments
Tags
Creators
Details
Python MOD
Write Minecraft mods in Python.
Python MOD is a Fabric mod that embeds a real Python 3 runtime into Minecraft
and exposes a sandboxed API for registering content, hooking game events and
building screens. Drop a .pymod file into a folder, restart, and your Python
is running inside the game — no Java, no Gradle, no recompiling.
- Minecraft 1.21.1 · Fabric Loader 0.16+ · Fabric API
- Runtime: GraalPy 24.2.2 (CPython 3.11 compatible)
- API level: 1
- Licence: Available License — © 2026 kscm · Project Owner: kscm
Hello, world
hello_world.pymod is a zip with two files:
hello_world.pymod
├── pymod.json
└── src/
└── main.py
pymod.json
{
"id": "hello_world",
"name": "Hello World",
"version": "1.0.0",
"api_version": 1,
"entrypoint": "src/main.py",
"permissions": []
}
src/main.py
import pythonmod
from pythonmod import registry, events
registry.block("hello_block", hardness=1.0, sounds="wood", name="Hello Block")
@events.player_join
def greet(player):
player.send_message("Hello from Python!")
Put it in .minecraft/mods/pythonmods/, start the game, press P.
What the API covers
pythonmod.registry — content
| Function | Registers |
|---|---|
block(id, hardness, resistance, requires_tool, luminance, sounds, group, item, texture, name) |
A full-cube block, optionally with a block item |
item(id, max_stack, group, texture, name) |
A plain item |
food(id, nutrition, saturation, always_edible, max_stack, group, texture, name) |
An edible item |
Everything is namespaced under your module id, so block("ruby_ore") in a
package with "id": "gems" becomes gems:ruby_ore. Two unrelated packages can
both define ruby_ore and never collide.
Models and English names are generated into a PythonMOD Generated resource
pack, so new content is usable before you have drawn a single texture.
pythonmod.events — game hooks
| Event | Arguments |
|---|---|
server_tick |
(server) |
server_started / server_stopping |
(server) |
player_join / player_leave |
(player) |
block_break |
(player, pos, block) |
block_use |
(player, pos) |
item_use |
(player) |
client_tick |
(client) |
from pythonmod import events
@events.block_break
def on_break(player, pos, block):
player.send_actionbar("broke %s at %d,%d,%d" % (block.id(), pos.x(), pos.y(), pos.z()))
An unrecognised event name raises at subscription time. A hook that silently never fires is the most expensive kind of typo, so it is not allowed to happen.
pythonmod.gui — screens
from pythonmod import gui
(gui.screen("Ore Scanner")
.size(280, 160)
.label("Radius", 20, 34)
.slider("blocks", 20, 48, 240, value=16, min=4, max=64, on_change=set_radius)
.checkbox("Include deepslate", 20, 80, checked=True, on_change=set_deepslate)
.button("Scan", 20, 110, 240, 20, on_click=scan)
.open())
Coordinates are relative to the panel and the panel is centred for you. Light and dark palettes are built in and follow the player's setting — a script never picks colours by hand.
On a dedicated server there is no client, so open() returns False and logs
a warning instead of raising. Guard with gui.available() when a package is
meant to run on both sides.
pythonmod.xmlgui — declarative HUD (XML)
Declare an in-game HUD panel in gui/*.xml — no Python screen code required.
Each module's gui/ folder is scanned automatically at load time.
gui/hud.xml
<gui>
<screen id="hud_demo" show_page="hud" x="12" y="12">
<label id="title" text="Python MOD · XML HUD" x="0" y="0"
style="color:#1b1f24" style_dark="color:#eceff3"/>
<divider x="0" y="20" w="180"/>
<button id="mine" text="Mine +1" x="0" y="30" w="160" h="20"
style="bg:#1565c0;color:#ffffff" style_dark="bg:#5fa8ff;color:#0b0e12"
click_script="on_click.py" follow_script="after.py"/>
<checkbox id="auto" text="Auto mine" x="0" y="58" checked="true"
style="color:#1b1f24" style_dark="color:#eceff3"
click_script="on_toggle.py"/>
<slider id="speed" text="Speed" x="0" y="84" w="180" value="0.5" min="0" max="1"/>
<text_field id="status" placeholder="Status: ready" x="0" y="110" w="180" h="20"/>
</screen>
</gui>
| Attribute | On | Meaning |
|---|---|---|
style / style_dark |
widget | CSS-like key:value pairs (color, bg, border). The active light/dark theme picks which one |
click_script |
button, checkbox | Python file run when the widget is clicked |
follow_script |
button | Python file run right after click_script completes |
show_page |
screen | Where to show it: hud (in-game overlay). screen / main_menu are reserved |
x / y |
screen, widget | Coordinates in GUI pixels; the screen is the panel anchor, a widget is relative to it |
The scripts run in an isolated context — same permissions and sandbox as the
module, but a crash disables only that script, never the whole module. They can
import pythonmod like any other code. A clicked button runs click_script
then follow_script; the same path is reachable from Python:
from pythonmod import xmlgui
xmlgui.trigger("hud_demo", "mine") # runs on_click.py then after.py
See examples/xml_hud_demo/ for a complete, runnable panel (button, checkbox,
slider and text field with light/dark styles and Python scripts).
The manifest
| Field | Required | Notes |
|---|---|---|
id |
yes | 2–64 chars of a-z, 0-9, _. Becomes your registry namespace |
version |
yes | Free-form |
api_version |
yes | Must be 1. See Attribution below |
name |
no | Defaults to id |
description |
no | Shown in the manager screen |
authors |
no | Array of strings |
entrypoint |
no | Defaults to src/main.py |
environment |
no | *, client or server |
permissions |
no | See the table below |
Permissions
| Permission | Risk | Grants |
|---|---|---|
file_read |
low | Reading inside your own data folder |
file_write |
medium | Writing inside your own data folder |
network |
high | Outbound sockets |
thread |
high | Creating threads |
native |
critical | Native/JNI access — effectively no sandbox |
process |
critical | Spawning processes — effectively no sandbox |
The sandbox confines file access to pythonmod/data/<your-id>/ regardless of
what is granted. pythonmod.data_path("state.json") builds a legal path;
anything outside that tree is refused before it reaches the filesystem.
Packages asking for native or process produce a loud warning in the log and
a red entry in the manager screen. That is deliberate: those two permissions
hand a script the same power a Java mod has.
In-game manager
Press P (rebindable) for:
- what loaded, what failed, and the exact reason
- what each package registered, and how long it took
- the permissions it was granted, colour-coded by risk
- the last few lines it printed
- a light / dark / auto theme switch, saved to the config
Configuration
.minecraft/config/pythonmod.json
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch |
autoDownloadRuntime |
true |
Fetch GraalPy on first launch |
mavenRepository |
Maven Central | Mirror to download from |
verifyChecksums |
true |
Verify every downloaded jar |
theme |
"auto" |
auto, light or dark |
callbackTimeoutMs |
5000 |
A callback exceeding this disables its package |
verboseLogging |
false |
Extra diagnostics |
How it works
preLaunch RuntimeProvisioner downloads GraalPy, verifies SHA-1,
injects it into the Knot class loader
↓
main PythonEngine one shared Engine
SandboxPolicy one Context per package, with its own
permission set and virtual filesystem
PyModLoader scan → validate → unpack → run
PyBridge the single Java object Python can reach
↓
runtime PyEventBridge Fabric callbacks fan out to Python hooks
PyGuiBridge screen descriptions, rendered client-side
XmlHudRenderer XML-declared HUD panels, scanned from gui/*.xml
XmlGuiBridge fires a widget's click/follow scripts
The runtime is not bundled. GraalPy is roughly 200 MB — larger than most
modpacks. It is downloaded once on first launch, checksum-verified, and cached
in .minecraft/pythonmod/runtime/. If the download fails the game still starts;
Python support is simply reported as unavailable.
One context per package, not one shared context. A polyglot context cannot
hold two different permission sets, so a shared one would grant every package
the union of everyone's permissions — one package asking for network would
silently hand network access to all the others. Separate contexts also mean a
crashing package can be disabled on its own.
Guest exceptions never escape. Every call into Python goes through
PythonEngine.invoke, which catches the guest exception, prints the Python
traceback, and disables the offending package. A typo in a script is not
allowed to take the server down.
Building
Requires JDK 21. A local Temurin 21 lives in .tooling/jdk-21; point
org.gradle.java.home in gradle.properties at your own if you prefer.
./gradlew build # builds the mod jar
./gradlew packExamples # packs examples/ into build/pymods/*.pymod
Attribution
Python MOD is distributed under the Available License (see LICENSE),
© 2026 kscm, Project Owner kscm.
Section 6 requires software using this API to state the original project name and version. Python MOD does that for you:
api_versionis mandatory in every manifest — a package without it is refused rather than silently loaded- the loader prints
<package> v<version> — Powered by Python MOD v<x.y.z> (API <n>)when the package starts - the same string is available as
pythonmod.ATTRIBUTION gradlew packExampleswritesATTRIBUTION.txtinto every archive
Declaring api_version is therefore all an author has to do.
Section 3 — using this project's source code requires authorisation from the Project Owner, and any such use must state the original project name, team and version.
Section 7 — "Python MOD" is used as the sole project name; no translated name is claimed or authorised.
Not affiliated with Mojang Studios or Microsoft.


