Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased
* Support baking `PythonCall` into a juliacall system image via the new opt-in
`embedded` preference / `JULIA_PYTHONCALL_EMBEDDED` option, removing the
`using PythonCall` cost from cold start. No behaviour change unless opted in.

## 0.9.34 (2026-05-18)
* Bug fixes.

Expand Down
20 changes: 20 additions & 0 deletions docs/src/juliacall.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ systems that may be readonly. Note that the project set in
`PYTHON_JULIACALL_PROJECT` *must* already have PythonCall.jl installed and it
*must* match the JuliaCall version, otherwise loading Julia will fail.

### Baking PythonCall into a system image

For the fastest possible startup you can compile `PythonCall` itself (alongside
your own packages) into a system image with
[PackageCompiler.jl](https://github.com/JuliaLang/PackageCompiler.jl), so that
the `using PythonCall` performed at startup is a memory-map rather than a load.

When `PythonCall` is baked into the system image its `__init__` runs *during*
`jl_init_with_image`, before juliacall's bootstrap has defined the
`Main.__PythonCall_libptr` global it normally uses to detect that it is
embedded. To support this, set the `embedded` preference (or the
`JULIA_PYTHONCALL_EMBEDDED=yes` environment variable) together with the `lib`
preference / `JULIA_PYTHONCALL_LIB` pointing at the running interpreter's
libpython. With `embedded` set, PythonCall takes the embedded path even without
the global and opens libpython by path (it is already loaded in the process, so
this is just a handle). The default is `no`, leaving normal behaviour
unchanged. Use this together with `PYTHON_JULIACALL_SYSIMAGE` (below), and
`PYTHON_JULIACALL_EXE` / `PYTHON_JULIACALL_PROJECT` so juliacall resolves the
baked environment directly.

## [Configuration](@id julia-config)

Some features of the Julia process, such as the optimization level or number of threads, may
Expand Down
31 changes: 28 additions & 3 deletions src/C/context.jl
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,36 @@ on_main_thread

function init_context()

CTX.is_embedded = hasproperty(Base.Main, :__PythonCall_libptr)
# Normally PythonCall is embedded when Python (via juliacall) defines the
# global `Main.__PythonCall_libptr`, set by juliacall's bootstrap *after*
# `jl_init_with_image`. If PythonCall is baked into a juliacall system
# image, its `__init__` runs *during* `jl_init_with_image` — before that
# global exists — yet we are still embedded (Python is the running host).
# The opt-in `embedded` preference / `JULIA_PYTHONCALL_EMBEDDED` forces the
# embedded path in that case; libpython is obtained by path since it is
# already loaded in this process. Unset, behaviour is unchanged.
has_libptr = hasproperty(Base.Main, :__PythonCall_libptr)
CTX.is_embedded = has_libptr || Utils.getpref_embedded()

if CTX.is_embedded
# In this case, getting a handle to libpython is easy
CTX.lib_ptr = Base.Main.__PythonCall_libptr::Ptr{Cvoid}
if has_libptr
# In this case, getting a handle to libpython is easy
CTX.lib_ptr = Base.Main.__PythonCall_libptr::Ptr{Cvoid}
else
# Baked into a sysimage: open libpython by path (the `lib`
# preference / JULIA_PYTHONCALL_LIB). dlopen of an
# already-loaded library just returns a handle to it.
lib_path = something(Utils.getpref_lib(), Some(nothing))
lib_path === nothing && error(
"JULIA_PYTHONCALL_EMBEDDED is set but libpython is unknown; " *
"set the `lib` preference or JULIA_PYTHONCALL_LIB to its path.",
)
lib_ptr = dlopen_e(lib_path, CTX.dlopen_flags)
lib_ptr == C_NULL &&
error("Python library $(repr(lib_path)) could not be opened.")
CTX.lib_path = lib_path
CTX.lib_ptr = lib_ptr
end
init_pointers()
# Check Python is initialized
Py_IsInitialized() == 0 && error("Python is not already initialized.")
Expand Down
1 change: 1 addition & 0 deletions src/Utils/Utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ checkpref(::Type{String}, x::AbstractString) = convert(String, x)
getpref_exe() = getpref(String, "exe", "JULIA_PYTHONCALL_EXE", "")
getpref_lib() = getpref(String, "lib", "JULIA_PYTHONCALL_LIB", nothing)
getpref_pickle() = getpref(String, "pickle", "JULIA_PYTHONCALL_PICKLE", "pickle")
getpref_embedded() = getpref(String, "embedded", "JULIA_PYTHONCALL_EMBEDDED", "no") == "yes"

function explode_union(T)
@nospecialize T
Expand Down
Loading