What Actually Happens When You Run python3 hello.py
Running a simple Python script on Linux feels like a single operation, but the journey from your shell prompt to printed output involves dozens of steps: parsing, path resolution, process creation, binary loading, dynamic linking, and finally writing to a terminal. Here’s how to observe each stage yourself using standard Linux tools.
print("hello world")
$ python3 hello.py
hello world
This walkthrough focuses on the general mechanics of running any dynamically linked executable. The Python-specific internals don't matter here; what happens between pressing Enter and seeing hello world is a story about how Linux launches binaries.
Before the Kernel Sees Anything
The shell does a lot of work before the kernel is even involved in running your program. It must parse the command string, resolve the executable’s location, and set up a new process.
Parsing and Path Resolution
First, the shell splits the command line into an executable name and an argument list. If you type python3 *.py, glob expansion turns that into python3 hello.py before anything else happens.
Next, the shell must find the binary for python3. It checks each directory in the PATH environment variable in order. You can see your own path with echo $PATH.
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
The shell uses the stat system call to test whether each candidate file exists. Running strace -e stat followed by a command like python3 reveals the search process in action.
stat("/usr/local/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/local/bin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/bin/python3", {st_mode=S_IFREG|0755, st_size=5479736, ...}) = 0
You can observe that it stops searching once it finds /usr/bin/python3. If your libc version uses a different syscall for this (like newfstatat), try strace -o out bash and then grep stat out.
If you need to replicate this PATH search in your own programs, the libc function execvp (or any exec* function with a p) does exactly that.
What stat Actually Reads
File access happens in two stages: mapping a filename to an inode (the metadata structure), then reading data blocks from that inode. The stat call only retrieves the inode’s contents; it never touches the file’s data. That makes it fast enough for path searching.
You can locate the actual inode on disk with debugfs. First find your block device:
$ df
...
tmpfs 100016 604 99412 1% /run
/dev/vda1 25630792 14488736 10062712 60% /
...
Then ask the filesystem where the inode for /usr/bin/python3 resides:
$ sudo debugfs /dev/vda1
debugfs 1.46.2 (28-Feb-2021)
debugfs: imap /usr/bin/python3
Inode 6206 is part of block group 0
located at block 658, offset 0x0d00
With the block number and offset in hand, you can calculate the byte position on disk (block_size * block_number + offset) and dump that region directly with dd:
$ sudo dd if=/dev/vda1 bs=1 skip=2698496 count=256 2>/dev/null | hexdump -C
00000000 ff a1 00 00 09 00 00 00 f8 b6 cb 64 9a 65 d1 60 |...........d.e.`|
00000010 f0 fb 6a 60 00 00 00 00 00 00 01 00 00 00 00 00 |..j`............|
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000060 00 00 00 00 12 4a 95 8c 00 00 00 00 00 00 00 00 |.....J..........|
00000070 00 00 00 00 00 00 00 00 00 00 00 00 2d cb 00 00 |............-...|
00000080 20 00 bd e7 60 15 64 df 00 00 00 00 d8 84 47 d4 | ...`.d.......G.|
00000090 9a 65 d1 60 54 a4 87 dc 00 00 00 00 00 00 00 00 |.e.`T...........|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
The raw inode data contains the filename, confirming you’re looking at the right structure. The first two bytes encode the file mode. On little-endian systems, bytes ff a1 represent 0xa1ff, or octal 0120777. Man page inode(7) explains that the leading 012 means symbolic link, and the 777 is the permission mask. Verifying with ls -l confirms:
$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 9 Apr 5 2021 /usr/bin/python3 -> python3.9
Creating the Process
Unix starts new processes in two phases: it first clones itself with fork, then replaces that clone with the target binary using execve. Run strace -e clone bash and then execute python3 to see the fork in action.
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03788f1a10) = 3708100
The number shown is the PID of the new child. That child inherits from the shell: environment variables (viewable via cat /proc/PID/environ | tr '\0' '\n'), open file descriptors for stdout and stderr (ls -l /proc/PID/fd), the working directory, namespaces, cgroups, and user/group IDs.
The execve System Call
After forking, the shell calls execve(argv[0], argv, envp). To trace this, use strace -f -e execve bash and then run python3. The -f flag is essential—it makes strace follow the forked child into the exec.
[pid 3708381] execve("/usr/bin/python3", ["python3"], 0x560397748300 /* 21 vars */) = 0
The kernel then reads the target binary. Earlier we only saw file metadata via stat; now the file’s contents matter. For /usr/bin/python3, a symlink, the target name python3.9 is stored inside the inode itself, which is why the file occupies zero data blocks:
$ stat /usr/bin/python3
File: /usr/bin/python3 -> python3.9
Size: 9 Blocks: 0 IO Block: 4096 symbolic link
Device: fe01h/65025d Inode: 6206 Links: 1
...
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
The kernel resolves this symlink silently, without an extra syscall you could trace. The actual target file’s data blocks can be located with debugfs and read with dd:
$ debugfs /dev/vda1
debugfs: blocks /usr/bin/python3.9
145408 145409 145410 145411 145412 145413 145414 145415 145416 145417 145418 145419 145420 145421 145422 145423 145424 145425 145426 145427 145428 145429 145430 145431 145432 145433 145434 145435 145436 145437
$ dd if=/dev/vda1 bs=4096 skip=145408 count=1 2>/dev/null | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |[email protected]...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
The contents match what cat would produce:
$ cat /usr/bin/python3.9 | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |[email protected]...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
ELF and the Interpreter
The file begins with the ASCII sequence ELF, a magic number identifying the binary format. Different formats use distinct magic numbers (for instance, gzip files start with 1f 8b), which is how tools like file classify data.
Parse the ELF header with readelf -a /usr/bin/python3.9. Two fields matter most:
$ readelf -a /usr/bin/python3.9
ELF Header:
Class: ELF64
Machine: Advanced Micro Devices X86-64
...
-> Entry point address: 0x5ea5c0
...
Program Headers:
Type Offset VirtAddr PhysAddr
INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
0x000000000000001c 0x000000000000001c R 0x1
-> [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
-> 1238: 00000000005ea5c0 43 FUNC GLOBAL DEFAULT 13 _start
The ELF header tells the kernel to invoke a different program first—the dynamic linker at /lib64/ld-linux-x86-64.so.2—and specifies the entry point where the program’s own code begins. The dynamic linker, not the kernel, is responsible for loading all required shared libraries before the program’s own _start code executes.
Dynamic Linking in Userspace
Immediately after execve, a strace of the Python interpreter shows a flurry of openat calls:
execve("/usr/bin/python3", ["python3"], 0x560af13472f0 /* 21 vars */) = 0
brk(NULL) = 0xfcc000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32091, ...}) = 0
mmap(NULL, 32091, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f718a1e3000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 l\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=149520, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f718a1e1000
...
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
Notice the first library being opened: /lib/x86_64-linux-gnu/libpthread.so.0. You can see the complete list of dependencies with ldd:
$ ldd /usr/bin/python3.9
linux-vdso.so.1 (0x00007ffc2aad7000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2fd6554000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2fd654e000)
libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)
Dynamic linking runs entirely in userspace. To find libraries, the linker normally consults LD_LIBRARY_PATH (on macOS, DYLD_LIBRARY_PATH), and LD_PRELOAD can override any function you choose. But in the strace output above, there’s no path search happening—the dynamic linker maintains a cache at /etc/ld.so.cache, which you can see being opened early in the trace. That’s why you don’t see a long series of stat calls like the shell performed for the executable itself.
Other details in the trace—such as mprotect calls—mark loaded library code as read-only, a security measure. The full trace also includes calls like prlimit64, arch_prctl, and set_robust_list; their precise purposes are obscure even to experienced developers, but they relate to thread setup, CPU state, and process limits.
ldd Is Just a Shell Script
One surprise: ldd is not a dedicated tool. It’s a thin shell script that sets the environment variable LD_TRACE_LOADED_OBJECTS=1 and executes the binary normally:
$ LD_TRACE_LOADED_OBJECTS=1 python3
linux-vdso.so.1 (0x00007ffe13b0a000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f01a5a47000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f01a5a41000)
libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)
The dynamic linker itself is a runnable binary, so /lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9 achieves the same result.
Initialization Code in .init Sections
Each shared library may run setup code when loaded. The trace contains clues that pthread does exactly that:
set_tid_address(0x7f58880dca10) = 3709103
Inspect the ELF sections of the pthread library with readelf:
$ readelf -a /lib/x86_64-linux-gnu/libpthread.so.0
...
[10] .rela.plt RELA 00000000000051f0 000051f0
00000000000007f8 0000000000000018 AI 4 26 8
[11] .init PROGBITS 0000000000006000 00006000
000000000000000e 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 0000000000006010 00006010
0000000000000560 0000000000000010 AX 0 0 16
...
Disassembling the .init section with objdump shows what runs:
$ objdump -d /lib/x86_64-linux-gnu/libpthread.so.0
Disassembly of section .init:
0000000000006000 <_init>:
6000: 48 83 ec 08 sub $0x8,%rsp
6004: e8 57 08 00 00 callq 6860 <__pthread_initialize_minimal>
6009: 48 83 c4 08 add $0x8,%rsp
600d: c3
That call to __pthread_initialize_minimal comes from glibc’s startup code. These days libpthread is folded into glibc itself, but the initialization mechanism remains: the ELF format supports .init and .fini sections for code that runs at load and unload, plus .ctors/.dtors for constructor and destructor functions. The man elf page documents these sections.
$ man elf
.init This section holds executable instructions that contribute to the process initialization code. When a program starts to run
the system arranges to execute the code in this section before calling the main program entry point.
Finally Getting to _start
Once dynamic linking completes, control transfers to the executable’s entry point, typically a function called _start. From there, the Python interpreter runs its own logic to parse and execute your script. None of this concerns general Linux process execution anymore.
Printing the string, though, crosses back into system territory. Use ltrace to see which libc function Python calls:
$ ltrace -o out python3 hello.py
$ grep hello out
write(1, "hello world\n", 12) = 12
The output shows a call to write. Note that ltrace is substantially less trustworthy than strace—it intercepts library calls indirectly—but checking the CPython source confirms that the interpreter invokes write() in its output path.
The Role of libc
That write function is provided by libc, the C standard library. Libc is not just string functions; it handles memory allocation (malloc), file I/O, process execution (execvp), DNS lookup (getaddrinfo), and thread management (pthread). Most higher-level languages—Python, Ruby, Node, Rust—link against libc, but Go is a notable exception that calls Linux syscalls directly.
Your choice of libc matters because implementations differ. glibc and musl are the two major options. For example, musl’s getaddrinfo historically lacked TCP DNS support, which caused subtle resolution bugs for programs running on Alpine Linux containers.
A Terminal Detour
When Python calls write(1, ...), file descriptor 1 points to your terminal. That exposes a fun experiment: each pseudo-terminal (/dev/pts/*) can receive output from any process with write permission.
- Run
ls -l /proc/self/fd/1in one terminal; note the device (e.g.,/dev/pts/2). - In another terminal, run
echo hello > /dev/pts/2. - Return to the first terminal—you’ll see
helloprinted there.
For those wanting to explore comparable internals on macOS, the tooling differs: otool -L replaces ldd, otool covers readelf’s role, and dtruss or dtrace stand in for strace, though they require disabling System Integrity Protection. Utilities like sc_usage and fs_usage can collect syscall statistics without that step.



