Last Updated: June 01, 2026
- What’s the difference between a python package vs module
- What happens when you import a python module
- How do you make a package in your python project
- Only Import a part of a module
- Importing a module and applying an alias
- Importing modules outside your project folder
- How to import modules dynamically
- Conclusion
- Get notified automatically of new articles
- Related Articles
- Frequently Asked Questions
Beginner
Importing modules or packages (in other languages this would be referred to as libraries) is a fundamental aspect of the language which makes it so useful. As of this writing, the most popular python package library, pypi.org, has over 300k packages to import. This isn’t just important for importing of external packages. It also becomes a must when your own project becomes quite large. You need to make sure you can split your code into manageable logical chunks which can talk to each other. This is what this article is all about.
Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.
What’s the difference between a python package vs module
First, some terminology. A module, is a single python file (still with a .py extension) that contains some code which you can import. While a package, is a collection of files. In your project, a package is all the files in a given directory and where the directory also contains the file __init__.py to signal that this is a package.
What happens when you import a python module
There is nothing special in fact you need to do to make a module – all python files are by default a module and can be imported. When a file is imported, all the code does get processed – e.g. if there’s any code to be executed it will run.
See following example. Suppose we have the following relationship:

Code as follows:
#module1.py
print("module1: I'm in module 1 root section")
def output_hw():
print("module1: Hello world - output_hw 1")
#module2.py
import module1
print("module2: I'm in root section of module 2")
def output_hw():
print("module2: Hello world - output_hw 2")
#main_file.py
print("main_file: starting code")
import module1
import module2
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
module1.output_hw()
module2.output_hw()
print("main_file: Main file done!")
Output:

So what’s happening here:
- The main_file.py gets executed first and then imports module1 then module2
- As part of importing
module1, it executes all the code including the print statements in the root part of the code. Similarly formodule2 - Then the code returns to the main_file where it calls the functions under module1 and
module2. - Please note, that both
module1andmodule2have the same function name ofoutput_hw(). This is perfectly fine as the scope of the function is in different modules.
One additional item to note, is that the module2 also imports module1. However, the print statement in the root section print("module1: I'm in module 1 root section") did not get executed the second time. Why? Python only imports a given module once.
Now let’s make a slight change – let’s remove the references to module1 in the main_file, and in module2, import module1!

The updated code looks like this:
#module1.py
print("module1: I'm in module 1 root section")
def output_hw():
print("module1: Hello world - output_hw 1")
#module2.py
import module1
print("module2: I'm in root section of module 2")
def output_hw():
print("module2: Hello world - output_hw 2")
#main_file.py
print("main_file: starting code")
# import module1
import module2
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
module2.output_hw()
# module2.output_hw()
print("main_file: Main file done!")
Output:

Now notice that module1 gets imported and executed from module2. Notice that the first line is “module1: I’m in module 1 root section” since the very first line of module2 is to import module1!
How do you make a package in your python project
To create a package it’s fairly straightforward. You simply need to move all your files into a directory and then create a file called __init__.py.
This means your directory structure looks like this:
/main_file.py
└── package1/
├── __init__.py
├── module1.py
└── module2.py
The above example, would now look like the following:
#__init__py
import package1.module1
import package1.module2
#module1.py
print("module1: I'm in module 1 root section")
def output_hw():
print("module1: Hello world - output_hw 1")
#module2.py
import package1.module1
print("module2: I'm in root section of module 2")
def output_hw():
print("module2: Hello world - output_hw 2")
#main_file.py
print("main_file: starting code")
import package
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
package1.module1.output_hw()
package1.module2.output_hw()
print("main_file: Main file done!")
So in the __init__.py file, it imports module1 & module2. The reason this is important is because so that when in main_file the package1 is imported, then it will have immediate access to module1 and module2. This is why the package1.module1 and package1.module2 works.
You cannot make the inclusion of modules automatic, and generally you shouldn’t as you may have name clashes which you can avoid if you do this manually.
Can you avoid typing the prefix of “package1” each time? Yes in fact if you use the “from”. See next section.
Only Import a part of a module
You can also import just either a class or a function of a given module if you prefer in order to limit what is accessible in your local code. However, it does still execute your whole module though. It is more a means to make your code much more readable. See the following example:
#module1.py
print("module1: I'm in module 1 root section")
def output_hw():
print("module1: Hello world - output_hw 1")
#main_file.py
print("main_file: starting code")
from module1 import output_hw
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
output_hw()
print("main_file: Main file done!")
Output

As can be seen in the above output, although just the output_hw() function is being imported, the statement “module1: Im in module1 root section” was still executed.
Note also, that you do not need to mention the module prefix in the code, you can just refer to the function as is.
So back to above, for the packages, instead of the following:
import package1.module1
you can instead use the “from” keyword but force to check local directory:
from .module1 import *
There’s a few things going on here. The '.' in front of module1 is referring to the current directory. If you wanted to check the parent directory then you can use two '.'s so the line looks like this: from ..module1 import *. The second item is that everything is being imported with the import * section.
Importing a module and applying an alias
In case you wanted to make your code easier to read, or you wanted to avoid any name clashes (see at the start of the article how module1 and module2 both had the same function name of output_hw() ), you can use the “as” keyword at the import statement to give an alternative name.
You can do the following:
#main_file.py
print("main_file: starting code")
from module1 import output_hw as module1__output_hw
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
module1__output_hw()
print("main_file: Main file done!")
This can also be done with the module or package name as well, i.e.
import module1 as mod1
Importing modules outside your project folder
Modules can by default be imported from the sub-directories up to the main script file. So the following works:
/main_file.py
└── package1/
│ ├── __init__.py
│ ├── module1.py
│ └── module2.py
└── package2/
├── __init__.py
└── pkg2_mod_a.py
Then in module1, you can import from pkg2_mod_2 with the following:
#module1.py
from package2.pkg2_mod_a import get_main_list
def output_hw():
print("module1: List from pkg2 module A:" + str( get_main_list()) )
Just need to remember in package2/__init__.py that you have to import pkg2_mod_a.py
However, what if the code was outside your main running script? Suppose if you had the following directory structure:
/
└── server_key.py
/r1/
└── main_file.py
└── package1/
├── __init__.py
└── module1.py
From any file in the /r1/ project, if you tried to import a file from server_key.py , you will get the error:
ValueError: attempted relative import beyond top-level package
To resolve this, you can in fact tell python where to look. Python keeps track of all the directories to search for modules under sys.path folder. Hence, the solution is to add an entry for the parent directory. Namely:
import sys
sys.path.append("..")
So the full code looks like the following:
#main_file.py
import sys
sys.path.append("..")
print("main_file: starting code")
import package1
print("main_file: I'm in the root section ")
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
package1.module1.output_hw()
print("main_file: Main file done!")
#module1.py
from package2.pkg2_mod_a import get_main_list
from server_key import get_server_master_key
def output_hw():
print("module1: List from pkg2 module A:" + str( get_main_list()) )
print("module1: server key :" + get_server_master_key() )
#server_key.py
def get_server_master_key():
return "AA33FF1255";
Output – The output is as follows:

How to import modules dynamically
All of the above is when you know exactly what the module name to import. However, what if you don’t know the module name until runtime?
This is where you can use the __import__ and the getattr functions to achieve this.
Firstly the getattr(). This function is used to in fact load an object dynamically where you can specify the object name in a string, or provide a default.
Secondly, the __import__() can be used to provide a module name as a string.
When you combine the two together, you first load the module with __import__, and then use getattr to load the actual function you want to call or class you want to load from the import.
See the following example:
/r1/
└── main_file.py
└── package1/
├── __init__.py
└── module1.py
With the following code:
#module1.py
def output_hw():
print("module1: take me to a funky town")
#main_file.py
if __name__ == '__main__':
print("main_file: ******* starting __main__ section")
module = __import__( 'package1.module1')
func = getattr( module, 'output_hw', None)
if func:
func()
print("main_file: Main file done!")
In the above code, we first load the module called “package1.module1” which only loads the module. Then the getattr is called on the module and then the function is passed as a string. You can also pass in a class name if you wish.
Conclusion
There are many ways to import files and to organize your projects into smaller chunks. The most difficult piece is to decide what parts of your code go where..
Get notified automatically of new articles
We are always here to help provide useful articles with usable ode snippets. Sign up to our newsletter and receive articles in your inbox automatically so you won’t miss out on the next useful tips.
How To Enable and Use Python’s Experimental JIT
Intermediate
You’ve probably heard that Python is slow — and then maybe heard that CPython 3.13 ships with an experimental JIT compiler that promises to change that. If you’re running a data-processing loop that takes 30 seconds and wondering whether flipping one environment variable can shave time off it, you’re in the right place. Python’s new JIT is not magic, but it is real, measurable, and already available in the standard distribution on some platforms.
The experimental JIT in CPython 3.13 uses a technique called copy-and-patch compilation. It targets the same bytecode that Python’s specializing adaptive interpreter (introduced in PEP 659) already optimizes — but instead of interpreting optimized bytecode, it compiles it to native machine code at runtime. You don’t need Numba, Cython, or any third-party library. The JIT is built into CPython itself, though you do need to ensure it’s enabled at both compile time and runtime.
This article covers: how the CPython JIT works under the hood, how to check whether your Python installation includes it, how to enable it with a single environment variable or flag, how to measure real speedups on CPU-bound code, how it pairs with free-threading (no GIL), and what workloads benefit most. By the end, you’ll be able to test the JIT on your own code and understand what gains to expect.
Enabling the JIT: Quick Example
If your CPython 3.13+ is compiled with JIT support (we’ll verify that below), enabling it takes one environment variable. Here’s a tight CPU-bound loop we can benchmark:
# jit_demo.py
def count_primes(n):
"""Count primes up to n using trial division."""
primes = 0
for num in range(2, n):
is_prime = True
for divisor in range(2, int(num ** 0.5) + 1):
if num % divisor == 0:
is_prime = False
break
if is_prime:
primes += 1
return primes
if __name__ == "__main__":
result = count_primes(50_000)
print(f"Primes found: {result}")
Output:
Primes found: 5133
Benchmark it with and without the JIT from the command line:
# Run without JIT (default)
time python jit_demo.py
# Run with JIT enabled
time PYTHON_JIT=1 python jit_demo.py
Sample output (CPython 3.13, x86-64 Linux):
--- Without JIT ---
Primes found: 5133
real 0m4.823s
--- With JIT ---
Primes found: 5133
real 0m4.312s
A roughly 10-11% speedup on a raw trial-division loop with zero code changes. The gains vary by workload — numeric loops improve more than I/O-bound code. The next sections explain why, and how to confirm the JIT is actually running on your machine.
What Is the CPython JIT and How Does It Work?
A just-in-time (JIT) compiler converts code to native machine instructions during program execution rather than ahead of time. Python has had various JIT experiments over the years — PyPy being the most mature — but CPython’s experimental JIT is different from all of them in one key way: it uses copy-and-patch compilation, designed specifically to integrate cleanly into CPython’s existing interpreter loop.
Here’s the simplified picture. CPython 3.11 introduced the Specializing Adaptive Interpreter, which watches which types flow through your functions and specializes bytecode opcodes for those types at runtime. Instead of a generic BINARY_OP instruction, the interpreter might specialize to BINARY_OP_ADD_INT — eliminating type checks on the hot path. The JIT in 3.13 takes those already-specialized bytecode sequences and converts them to native machine code by copying pre-compiled template machine code and patching in the runtime values (constants, addresses, type guards). No complex compiler passes, no runtime LLVM. This makes the JIT fast to compile and conservative about what it bets on.
| Approach | Example | Compile Time | Speedup | Code Changes? |
|---|---|---|---|---|
| Specializing Interpreter | CPython 3.11-3.13 | None | ~10-20% over 3.10 | No |
| Copy-and-patch JIT | CPython 3.13 (JIT on) | Microseconds per trace | ~5-15% additional | No (one env var) |
| Third-party JIT | Numba @jit | Seconds per function | 10-100x for numerics | Yes (decorator) |
| Alternative runtime | PyPy | Warmup seconds | 3-10x overall | Usually no |
The CPython JIT sits between the pure interpreter and a full JIT engine like Numba. Its design priority is correctness and compatibility over maximum speed — which is why the gains are modest in 3.13 but growing with each release. Unlike Numba, you don’t annotate functions or restrict yourself to a subset of Python: the JIT handles arbitrary Python code.
Step 1 — Check Whether Your Python Has JIT Support
The JIT must be compiled into CPython at build time. Not every binary distribution includes it — particularly on older Linux distros and macOS where the build flags differ. Here’s how to check:
# check_jit.py
import sys
def check_jit_support():
"""Check whether this CPython build includes JIT support."""
version = sys.version_info
print(f"Python {version.major}.{version.minor}.{version.micro}")
if version < (3, 13):
print("JIT requires Python 3.13 or later.")
return False
# sys._jit_active() is available in 3.13+ JIT-enabled builds
if hasattr(sys, '_jit_active'):
active = sys._jit_active()
print(f"JIT currently active: {active}")
print("JIT support compiled in: True")
return True
else:
print("JIT support compiled in: False")
print("Tip: build CPython with --enable-experimental-jit")
return False
check_jit_support()
Output (JIT-enabled build with PYTHON_JIT=1):
Python 3.13.0
JIT currently active: True
JIT support compiled in: True
Output (standard build or JIT=0):
Python 3.13.0
JIT support compiled in: False
Tip: build CPython with --enable-experimental-jit
If JIT is not available, the next section explains how to install a JIT-enabled Python using pyenv. If it is available, skip ahead to "Enabling the JIT at Runtime".
Step 2 -- Install a JIT-Enabled CPython via pyenv
pyenv lets you build CPython from source with custom configure flags. Building with JIT support requires LLVM 18 or later installed on your system, because the copy-and-patch templates are generated using LLVM at compile time (not at runtime).
# install_jit_python.sh
# macOS: install LLVM via Homebrew
brew install llvm@18
# Ubuntu/Debian: install LLVM
# sudo apt-get install llvm-18 clang-18
# Install CPython 3.13 with JIT enabled via pyenv
PYTHON_CONFIGURE_OPTS="--enable-experimental-jit" pyenv install 3.13.0
# Set it as the local Python version for this project
pyenv local 3.13.0
# Verify the build succeeded
python --version
python -c "import sys; print(hasattr(sys, '_jit_active'))"
Output:
Python 3.13.0
True
The build takes 2-5 minutes. If it fails with a "LLVM not found" error, ensure llvm-config is on your PATH. On macOS with Homebrew, add $(brew --prefix llvm@18)/bin to your PATH before running pyenv install. On Linux, the clang-18 package installs llvm-config-18 -- you may need to symlink it to llvm-config.
Step 3 -- Enable the JIT at Runtime
Once you have a JIT-capable build, there are two equivalent ways to enable the JIT at runtime. Choose whichever fits your workflow:
# enable_jit.sh
# Option 1: Environment variable (applies to all Python processes in this shell session)
export PYTHON_JIT=1
python my_script.py
# Option 2: Command-line flag (applies to this invocation only)
python -X jit my_script.py
# Option 3: Explicitly disable JIT even when PYTHON_JIT=1 is set globally
python -X jit=0 my_script.py
# Confirm the JIT is actually active inside your script:
python -c "import sys; print('JIT active:', sys._jit_active() if hasattr(sys, '_jit_active') else 'not available')"
Output (JIT active):
JIT active: True
The PYTHON_JIT=0 / -X jit=0 form lets you toggle the JIT off for debugging without rebuilding Python -- useful when you suspect the JIT is producing incorrect output (rare, but possible with experimental features). Always verify with sys._jit_active() before drawing conclusions from benchmark results.
Step 4 -- Benchmark the JIT on CPU-Bound Code
A single time call is noisy. The right approach is to run multiple iterations and measure the median. Python's built-in timeit module handles this cleanly:
# benchmark_jit.py
import timeit
import statistics
import sys
def fibonacci_loop(n):
"""Compute the nth Fibonacci number iteratively."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
def mandelbrot_point(c, max_iter=100):
"""Return iteration count for complex number c in Mandelbrot set."""
z = 0
for i in range(max_iter):
if abs(z) > 2:
return i
z = z * z + c
return max_iter
def mandelbrot_grid(width=200, height=150):
"""Sum iterations across a Mandelbrot grid."""
total = 0
for row in range(height):
for col in range(width):
c = complex(
-2.5 + col * 3.5 / width,
-1.25 + row * 2.5 / height
)
total += mandelbrot_point(c)
return total
# Check JIT status
jit_on = sys._jit_active() if hasattr(sys, '_jit_active') else False
print(f"Python {sys.version_info.major}.{sys.version_info.minor} | JIT active: {jit_on}")
print()
benchmarks = [
("fibonacci_loop(10_000)", {"fibonacci_loop": fibonacci_loop}),
("mandelbrot_grid()", {"mandelbrot_grid": mandelbrot_grid, "mandelbrot_point": mandelbrot_point}),
]
REPEAT = 5
for stmt, globs in benchmarks:
times = timeit.repeat(stmt, globals=globs, number=10, repeat=REPEAT)
med_ms = statistics.median(times) * 1000
print(f"{stmt}: {med_ms:.1f} ms (median of {REPEAT} runs x 10)")
Output without JIT (python benchmark_jit.py):
Python 3.13 | JIT active: False
fibonacci_loop(10_000): 4.2 ms (median of 5 runs x 10)
mandelbrot_grid(): 3891.4 ms (median of 5 runs x 10)
Output with JIT (PYTHON_JIT=1 python benchmark_jit.py):
Python 3.13 | JIT active: True
fibonacci_loop(10_000): 3.8 ms (median of 5 runs x 10)
mandelbrot_grid(): 3423.6 ms (median of 5 runs x 10)
The Mandelbrot benchmark shows ~12% improvement -- a classic JIT win because the inner loop executes the same operations on complex numbers thousands of times, giving the JIT enough warmup to pay off. The JIT is most effective on tight, repeated loops with stable types. Note that first-run times may be slightly slower due to compilation overhead -- the timeit repeat approach captures steady-state performance, which is what matters for long-running processes.
Pairing the JIT with Free-Threading (No GIL)
Python 3.13 also ships an experimental "free-threaded" mode that disables the Global Interpreter Lock (GIL), allowing true CPU-level parallelism across multiple threads. The JIT and free-threading are independent features, but they can be enabled together -- and the combination is particularly interesting for CPU-bound multi-threaded workloads.
Free-threading requires its own special Python build (the 3.13t variant). You can check whether your build supports it and whether the GIL is currently enabled:
# check_freethreading.py
import sys
def check_free_threading():
"""Check if this build supports free-threading."""
if not hasattr(sys, '_is_gil_enabled'):
print("Free-threading not available in this build.")
print("Install a 't' variant: pyenv install 3.13t")
return False
gil_on = sys._is_gil_enabled()
print(f"Free-threading build: True")
print(f"GIL currently enabled: {gil_on}")
if not gil_on:
print("Running in free-threaded mode -- true parallelism available.")
else:
print("To disable GIL: set PYTHON_GIL=0 or pass -X gil=0")
return True
check_free_threading()
Output (free-threaded build with GIL disabled):
Free-threading build: True
GIL currently enabled: False
Running in free-threaded mode -- true parallelism available.
To enable both the JIT and free-threading simultaneously:
# Enable both JIT and free-threading together
PYTHON_GIL=0 PYTHON_JIT=1 python my_script.py
# Or with flags
python -X gil=0 -X jit my_script.py
Keep in mind: both features are experimental in 3.13. Running them together amplifies that caveat. Use this combination for benchmarking and exploration, not production systems handling sensitive data. That said, most pure-Python code runs correctly under both flags. The free-threading + JIT combination is expected to become more stable through CPython 3.14.
What Workloads Benefit Most from the JIT
The JIT is not a universal speedup switch. Understanding what it targets helps you predict where it will help and where it won't change anything.
| Workload Type | JIT Benefit | Why |
|---|---|---|
| Tight numeric loops (int/float) | High (10-20%) | Stable types, many iterations, classic JIT target |
| Mandelbrot / fractal computation | High (10-15%) | Inner loop runs millions of times with same types |
| String processing loops | Moderate (5-10%) | Some specialization, but string ops are already fast C |
| Data parsing (JSON, CSV) | Low (0-5%) | Dominated by C extensions; JIT doesn't touch C code |
| Web requests / I/O | None | Blocked on network, not CPU computation |
| NumPy / Pandas operations | None | Core ops run in C; Python overhead already minimal |
| asyncio event loops | Low | Overhead is machinery, not tight computation loops |
The rule of thumb: if you're already considering Numba or Cython to speed something up, the CPython JIT is worth trying first. If the bottleneck is I/O, network, or C extension calls, the JIT won't help -- profile with cProfile first to locate the actual hot path before reaching for any optimization tool.
Real-Life Example: JIT-Aware Performance Benchmarking Tool
Let's build a small utility that auto-detects JIT availability and reports speedup across multiple test functions. This is the kind of tool you'd use to evaluate whether the JIT is worth enabling for a specific codebase before committing to it in a deployment environment.
# jit_benchmark_tool.py
import timeit
import statistics
import subprocess
import sys
import os
# Each benchmark is a self-contained Python snippet
BENCHMARK_FUNCTIONS = {
"prime_count_10k": """
def is_prime(n):
if n < 2: return False
for i in range(2, int(n**0.5)+1):
if n % i == 0: return False
return True
result = sum(1 for n in range(2, 10_000) if is_prime(n))
""",
"string_reverse_100k": """
result = 0
for i in range(100_000):
s = str(i)
result += len(s[::-1])
""",
"float_accumulate_1M": """
total = 0.0
for i in range(1_000_000):
total += i * 1.5 / (i + 1)
""",
}
def run_timed(enabled, stmt, repeat=3, number=5):
"""Run a benchmark snippet in a subprocess with JIT on or off."""
env = os.environ.copy()
env["PYTHON_JIT"] = "1" if enabled else "0"
script = f"""
import timeit, statistics
times = timeit.repeat(stmt='''{stmt}''', repeat={repeat}, number={number})
print(statistics.median(times))
"""
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, env=env
)
if result.returncode != 0:
return None
try:
return float(result.stdout.strip())
except ValueError:
return None
def main():
print(f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
print(f"JIT build: {hasattr(sys, '_jit_active')}")
print()
print(f"{'Benchmark':<28} {'No JIT':>10} {'JIT On':>10} {'Speedup':>10}")
print("-" * 62)
for name, stmt in BENCHMARK_FUNCTIONS.items():
t_base = run_timed(False, stmt)
t_jit = run_timed(True, stmt)
if t_base is None or t_jit is None:
print(f"{name:<28} {'error':>10} {'error':>10} {'N/A':>10}")
continue
speedup = t_base / t_jit if t_jit > 0 else float('inf')
print(f"{name:<28} {t_base*1000:>9.1f}ms {t_jit*1000:>9.1f}ms {speedup:>9.2f}x")
print("-" * 62)
print("Speedup > 1.0x means JIT is faster.")
if __name__ == "__main__":
main()
Output:
Python 3.13.0
JIT build: True
Benchmark No JIT JIT On Speedup
--------------------------------------------------------------
prime_count_10k 245.3ms 216.8ms 1.13x
string_reverse_100k 89.4ms 85.1ms 1.05x
float_accumulate_1M 312.7ms 271.4ms 1.15x
--------------------------------------------------------------
Speedup > 1.0x means JIT is faster.
This tool uses subprocesses to guarantee the JIT environment variable is cleanly applied per run with no cross-contamination. Add your own functions to BENCHMARK_FUNCTIONS -- just keep each statement self-contained with its own imports. The subprocess approach also means you can run this benchmarker from a non-JIT Python binary and still get accurate JIT measurements for the target interpreter.
Frequently Asked Questions
Is the CPython JIT safe for production use?
As of Python 3.13, the JIT is marked "experimental" for good reason -- it may produce incorrect results in edge cases, particularly with unusual bytecode patterns or less-tested platform and architecture combinations. For web servers and data pipelines processing real user data, stick with the default interpreter. For internal tools, batch jobs, and scientific computing where you can validate output independently, the JIT is worth testing. Run your full test suite with PYTHON_JIT=1 first and check for any failures before enabling it in a production environment.
Which platforms support the JIT?
As of CPython 3.13, the JIT builds and runs on x86-64 Linux, x86-64 macOS, ARM64 (Apple Silicon) macOS, and ARM64 Linux. Windows support is in progress but not fully stable as of 3.13. The key dependency is LLVM 18+ for generating the copy-and-patch templates at build time. Pre-built binaries from python.org do not include the JIT by default -- you need to build from source using pyenv with --enable-experimental-jit, or use a distribution that explicitly ships JIT-enabled builds.
How is this different from Numba's JIT?
Numba's @jit decorator compiles specific functions to native code using LLVM and requires those functions to use only Numba-supported Python and NumPy constructs. It can deliver 10-100x speedups on numeric code because it eliminates Python overhead entirely for the annotated functions. CPython's JIT is more conservative -- it handles arbitrary Python code, requires no annotations, and gains 5-15%. Think of Numba as a scalpel (surgical, powerful, specific) and the CPython JIT as a general performance floor-raise across your whole codebase. Use Numba where you need maximum numeric throughput; use the CPython JIT when you want an easy, broad improvement without touching your code.
Does the JIT need warmup time?
Yes, but the warmup is shorter than traditional JIT compilers. The copy-and-patch approach compiles traces (sequences of bytecode) on first execution and caches the compiled machine code. Short-running scripts (less than a second of CPU work) may see no improvement or even a slight slowdown on first run due to compilation overhead. The benchmarking tool above uses timeit.repeat with multiple runs to measure steady-state performance rather than cold-start time. For long-running servers and batch jobs, the warmup cost is negligible compared to total runtime.
Does the JIT increase memory usage?
The compiled machine code for each trace takes additional memory. In practice the overhead is modest -- typically a few megabytes for usual Python programs -- because the JIT only compiles hot traces (frequently executed code paths), not every function. Programs with many distinct hot paths (large web applications with thousands of routes) may see more overhead than programs with a small number of tight inner loops. Monitor RSS memory with resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if you're running in a memory-constrained environment.
How do I debug JIT-related errors?
Start by disabling the JIT with PYTHON_JIT=0 and checking whether the error disappears. If it does, the JIT is producing incorrect output for that code path -- this is a CPython bug and should be reported at bugs.python.org with a minimal reproducer. For diagnostic output about what the JIT is compiling, set PYTHON_LLTRACE=1 (very verbose). Most real bugs with the 3.13 JIT involve C extension interaction or unusual bytecode sequences from dynamic code generation such as eval() or exec(). Regular Python code rarely triggers JIT correctness issues.
Conclusion
Python's experimental JIT compiler in CPython 3.13 is the first native JIT built directly into the reference interpreter. It uses copy-and-patch compilation that targets already-specialized bytecode, requires no code changes, and delivers 5-15% speedups on CPU-bound loops. To use it, you need a JIT-enabled CPython build (compiled with --enable-experimental-jit) and a single environment variable: PYTHON_JIT=1. We covered how to verify JIT support, install it via pyenv, enable it at runtime, benchmark it with timeit, pair it with free-threading, and understand which workloads benefit most.
Try extending the benchmarking tool with your own CPU-bound functions -- sorting algorithms, recursive tree traversals, or text processing loops. The results will tell you immediately whether the JIT is worth enabling for your specific use case. For deeper reading, see the Python 3.13 What's New: JIT Compiler and the original design document at PEP 744 -- JIT Compilation.
As CPython 3.14 continues the JIT's development with improved coverage and larger speedups, keeping the JIT in your toolbox today means you'll be positioned to benefit more with each Python release -- without changing a line of your application code.
Related Articles
Related Articles
- How To Use Python pendulum for Better Date and Time Handling
- How To Use Python Type Hints For Better Code Quality
- How To Use Python responses for Mocking HTTP Requests in Tests
- How To Use Python freezegun for Mocking Time in Tests
- How To Use Python natsort for Natural Sort Order
- How To Use Python structlog for Structured Logging
- How To Split And Organise Your Source Code Into Multiple Files in Python 3
Further Reading: For more details, see the Python import system documentation.
Frequently Asked Questions
What is the difference between absolute and relative imports in Python?
Absolute imports use the full package path from the project root (e.g., from mypackage.module import func). Relative imports use dots to reference the current package (e.g., from .module import func). Absolute imports are generally preferred for clarity.
What does __init__.py do in a Python package?
The __init__.py file marks a directory as a Python package, allowing its modules to be imported. It can be empty or contain initialization code, define __all__ for controlling wildcard imports, or re-export symbols for a cleaner public API.
How do I fix ‘ModuleNotFoundError’ in Python?
Check that the module is installed (pip install), verify your PYTHONPATH includes the right directories, ensure __init__.py files exist in package directories, and confirm you are using the correct Python environment. Running from the project root often resolves path issues.
What is the best project structure for a Python application?
A common structure includes a top-level project directory containing a src/ folder with your package, a tests/ folder, setup.py or pyproject.toml, and a requirements.txt. This keeps source code, tests, and configuration clearly separated.
Should I use relative or absolute imports?
PEP 8 recommends absolute imports for most cases because they are more readable and less error-prone. Use relative imports only within a package when the internal structure is unlikely to change and the import path would be excessively long with absolute imports.
Continue Learning Python
Tutorials you might also find useful: