Skip to content

Debugging a dlopen ImportError in a universal2 wheel with nm and otool

I recently had to debug an ImportError in a compiled Python extension on macOS (an area of packaging I fortunately rarely need to touch) and it turned into a short tour of nm and otool.

The package in question was mssql-python, Microsoft's official Python driver for SQL Server, which I'd just added to a project. My machine is an M2 MacBook Air, and mssql-python ships "universal" binary wheels for macOS: a single .whl bundling both an x86_64 and an arm64 "slice" of each compiled extension.

The failure

It installed fine, but my first bit of code that tried to use the library failed with an ImportError. Here's a minimal reproduction on the M2 Mac:

$ uvx -q -p 3.14 --with 'mssql-python==1.8.0' python -c 'import mssql_python'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import mssql_python
  File "/Users/personal/.cache/uv/archive-v0/48A63Ylj3f_T58HN/lib/python3.14/site-packages/mssql_python/__init__.py", line 14, in <module>
    from .helpers import Settings, get_settings, _settings, _settings_lock
  File "/Users/personal/.cache/uv/archive-v0/48A63Ylj3f_T58HN/lib/python3.14/site-packages/mssql_python/helpers.py", line 11, in <module>
    from mssql_python import ddbc_bindings
  File "/Users/personal/.cache/uv/archive-v0/48A63Ylj3f_T58HN/lib/python3.14/site-packages/mssql_python/ddbc_bindings.py", line 129, in <module>
    module = importlib.util.module_from_spec(spec)
ImportError: dlopen(/Users/personal/.cache/uv/archive-v0/48A63Ylj3f_T58HN/lib/python3.14/site-packages/mssql_python/ddbc_bindings.cp314-universal2.so, 0x0002): symbol not found in flat namespace '__ZN7simdutf40convert_utf16le_to_utf8_with_replacementEPKDsmPc'

Confirming the .so is really there

I know enough about C extension development to know that the .so file is a Shared Object shipped inside the wheel. I downloaded the 1.8.0 wheel from PyPI and unzipped it to confirm the file was actually there:

$ unzip -q -o mssql_python-1.8.0-cp314-cp314-macosx_15_0_universal2.whl -d mssql_python-1.8.0
$ find mssql_python-1.8.0/mssql_python/ -regex '.*\.so'
mssql_python-1.8.0/mssql_python/ddbc_bindings.cp314-universal2.so

but I didn't yet know how to inspect the file to figure out what was going on with the missing symbol.

Tool #1: nm -u - find the missing symbol

After a quick search, I found nm. Its -u/--undefined-only flag looked like exactly what I needed: it lists the symbols a binary expects to be resolved by something else at load time.

$ nm -u mssql_python-1.8.0/mssql_python/ddbc_bindings.cp314-universal2.so | grep simdutf
__ZN7simdutf40convert_utf16le_to_utf8_with_replacementEPKDsmPc

That confirms the traceback: the .so needs a simdutf symbol that isn't available anywhere at import time. But nm alone doesn't say where that symbol was supposed to come from.

Tool #2: otool -L - find where it was supposed to come from

That's what otool -L is for: it lists the dynamic libraries a Mach-O binary is linked against, broken down per architecture slice.

$ otool -L mssql_python-1.8.0/mssql_python/ddbc_bindings.cp314-universal2.so

mssql_python-1.8.0/mssql_python/ddbc_bindings.cp314-universal2.so (architecture x86_64):

    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)
    /usr/local/opt/simdutf/lib/libsimdutf.34.dylib (compatibility version 34.0.0, current version 34.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.5)

mssql_python-1.8.0/mssql_python/ddbc_bindings.cp314-universal2.so (architecture arm64):

    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.5)

There it is: the x86_64 slice is linked against /usr/local/opt/simdutf/lib/libsimdutf.34.dylib - a Homebrew install path. That's a build-time absolute path that only resolves on whatever machine built the wheel, not on mine. The arm64 slice has no such dependency at all. Either way, the x86_64 slice of this wheel can't work on a machine without that exact Homebrew library installed at that exact path.

Is this mssql-python's code, or the wheel?

Same import, different platform

Running the same command inside the official arm64 python:3.14-trixie Docker image imports mssql_python without any crash:

docker run -it python:3.14-trixie sh
pip install uv
uvx -p 3.14 --with 'mssql-python==1.8.0' python -c 'import mssql_python'

That lines up with the otool -L finding above: the problem is specific to this macOS wheel's linking, not a bug in mssql-python's Python code.

Root cause: a build-time find_package fallback

Why does the x86_64 slice end up linked against a Homebrew path in the first place? The answer was in mssql_python/pybind/CMakeLists.txt:

find_package(simdutf CONFIG QUIET)

if(NOT simdutf_FOUND)
    include(FetchContent)
    # ...
    FetchContent_MakeAvailable(simdutf)
endif()

The build tries find_package(simdutf) first, and only falls back to FetchContent - which builds simdutf from source and statically links it - if that fails. On the project's macOS CI runner, Homebrew already has simdutf installed, so find_package succeeds and links the extension dynamically against that runner's Homebrew path, which obviously doesn't exist on anyone else's machine.

I filed an issue microsoft/mssql-python#607 with these findings and opened a PR microsoft/mssql-python#608, removing the find_package call so the build always goes through FetchContent and statically embeds simdutf instead. It was merged and shipped in 1.9.0.

Confirmed fixed in 1.9.0

The 1.9.0 wheel doesn't have this problem. Unzipping it the same way and re-running both tools shows no simdutf symbol at all:

$ nm -u mssql_python-1.9.0/mssql_python/ddbc_bindings.cp314-universal2.so | grep simdutf

and otool -L now agrees between architectures:

$ otool -L mssql_python-1.9.0/mssql_python/ddbc_bindings.cp314-universal2.so

mssql_python-1.9.0/mssql_python/ddbc_bindings.cp314-universal2.so (architecture x86_64):

    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.5)

mssql_python-1.9.0/mssql_python/ddbc_bindings.cp314-universal2.so (architecture arm64):

    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1345.120.2)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.5)

That matches the fix in the PR: no more Homebrew dependency, on either slice.

And, most importantly, re-running the original repro against 1.9.0 actually imports cleanly:

$ uvx -q -p 3.14 --with 'mssql-python==1.9.0' python -c 'import mssql_python'

Takeaway

For a dlopen/"symbol not found in flat namespace" error in a compiled Python extension: nm -u tells you what symbol is missing, and otool -L tells you where it was supposed to come from. Together they're often enough to spot a non-portable build - like a hardcoded Homebrew path - without needing the original build environment.

If you want to go deeper on both tools, this write-up on building nm and otool from scratch is a good read.

Glossary

  • macOS Universal Binary - a single Mach-O file that bundles machine code for more than one CPU architecture, so the same .so/executable runs natively on both.
  • Slice - the per-architecture chunk of a universal binary (e.g. the x86_64 slice or the arm64 slice). Each slice is linked independently and can have different dependencies.
  • Shared Library - code packaged separately from the program that uses it (a .dylib on macOS, .so on Linux) and loaded at runtime instead of being copied into the binary. Also called a "Shared Object" on Unix-like systems.
  • Symbol - the name a compiler/linker uses to refer to a function or variable across files, e.g. __ZN7simdutf40convert_utf16le_to_utf8_with_replacementEPKDsmPc. An "undefined" symbol is one a binary references but doesn't define itself.
  • Dynamic Link - resolving a symbol against a shared library at load/run time rather than at build time. This is what failed here: the required library wasn't present on my machine.
  • Static Link - copying a dependency's code directly into the output binary at build time, so there's no separate shared library to find at runtime.