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.

  1. Run ls -l /proc/self/fd/1 in one terminal; note the device (e.g., /dev/pts/2).
  2. In another terminal, run echo hello > /dev/pts/2.
  3. Return to the first terminal—you’ll see hello printed 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.