Multithreading in Python for beginners

Multithreading in Python (Beginner Guide): Threads, the GIL, and Free-Threading in 2026

Have you ever written a Python script that downloads a bunch of files, calls an API multiple times, or processes a stack of text files — and noticed it just… sits there? One task finishes, then the next starts, then the next. Nothing happens at the same time.

That’s where multithreading comes in.

Multithreading is a way of running multiple parts of your program “at the same time” (or close to it) instead of one after another. In Python, this is done using the built-in threading module, and it’s one of the most useful tools for speeding up programs that spend a lot of time waiting — waiting for a website to respond, waiting for a file to be read, waiting for a database query to come back.

In real-world Python development, multithreading shows up everywhere: web scrapers that fetch dozens of pages at once, scripts that send emails to a list of users, background tasks in Flask or Django apps, and automation scripts that monitor files while doing other work.

This guide is written for beginners, but we’ll also touch on something important happening in the Python world right now: Python 3.13 and 3.14 introduced “free-threaded” builds that change how the GIL works. We’ll explain what that means in plain English, without the jargon overload.

By the end of this article, you’ll understand:

  • What multithreading actually is (and isn’t)
  • How Python threads work under the hood
  • How to create, run, and manage threads
  • What the GIL is and why it matters
  • How multithreading compares to multiprocessing
  • How to avoid the most common beginner mistakes
  • Where multithreading is genuinely useful in 2026

Let’s get started.


What is Multithreading in Python?

Multithreading is a programming technique where a single program runs multiple threads — smaller units of execution — that can work concurrently within the same process.

Think of a process as an entire restaurant, and a thread as a worker inside that restaurant. Multithreading means having multiple workers (threads) operating inside the same restaurant (process), sharing the same kitchen, ingredients, and equipment (memory).

In Python, this is handled through the built-in threading module, which lets you:

  • Create new threads
  • Start and stop them
  • Make threads wait for each other
  • Share data between threads (carefully!)

Note: Multithreading does not automatically mean “faster CPU performance.” For certain types of tasks, it helps enormously. For others, it barely helps at all — or can even slow things down. We’ll explain exactly why a bit later when we talk about the GIL.

Why Multithreading Matters in Modern Python Development

Multithreading is especially useful for I/O-bound tasks — tasks where your program spends most of its time waiting for something external, such as:

  • Network requests (APIs, web pages)
  • Reading/writing files
  • Database queries
  • User input

While one thread is waiting for a response, another thread can keep working. This makes your program feel faster and more responsive, even if the total “work” being done hasn’t changed much.

If you’re just getting comfortable with how Python reads and processes files, it’s worth first reviewing how to read and write text files in Python — many multithreading examples (including ours) involve file operations.


How Python Threads Work

To understand multithreading, it helps to understand a few core ideas first.

Process vs Thread

ConceptDescription
ProcessAn independent running program with its own memory space
ThreadA smaller unit of execution inside a process, sharing memory with other threads in that process

When you run a Python script, you start a process. That process can then create multiple threads, and all of those threads share the same memory — meaning they can read and write the same variables, objects, and data structures.

This shared memory is a double-edged sword:

  • ✅ It makes communication between threads easy (no need to copy data around)
  • ⚠️ It also means threads can accidentally interfere with each other if you’re not careful (more on this in the Thread Synchronization section)

Threads in CPython

Python’s most common implementation, CPython, maps each Python thread to a real operating system thread. So when you create a thread in Python, the operating system is actually managing it at a low level.

However, CPython has a special mechanism called the Global Interpreter Lock (GIL) that controls how these threads execute Python code. We’ll dig into this shortly — it’s one of the most important (and most misunderstood) concepts in Python concurrency.


Understanding the Python Threading Module

Python’s standard library includes a module called threading, and it’s the foundation for almost everything in this guide.

Here’s a quick overview of the most commonly used pieces:

ComponentPurpose
ThreadRepresents a single thread of execution
start()Starts the thread
join()Waits for the thread to finish
is_alive()Checks if a thread is still running
LockPrevents multiple threads from accessing shared data at once
RLockA “re-entrant” lock that can be acquired multiple times by the same thread
SemaphoreLimits how many threads can access a resource at once
EventLets threads signal each other
daemonA special type of background thread

Don’t worry if some of these don’t make sense yet — we’ll go through each one with examples.


Creating Your First Thread

Let’s start with the simplest possible example: running a function in a separate thread.

import threading
import time

def greet(name):
    print(f"Hello, {name}!")
    time.sleep(2)
    print(f"Goodbye, {name}!")

# Create a thread
my_thread = threading.Thread(target=greet, args=("Alex",))

# Start the thread
my_thread.start()

print("This runs immediately, without waiting for greet() to finish.")

# Wait for the thread to finish
my_thread.join()

print("Now the thread has finished.")

What’s Happening Here?

  1. We import threading and time.
  2. We define a normal function called greet().
  3. We create a Thread object, telling it which function (target) to run and what arguments (args) to pass.
  4. We call .start() — this is what actually begins running the thread.
  5. The main program doesn’t wait for greet() to finish — it immediately moves to the next line.
  6. .join() tells the main program: “Wait here until this thread is done before continuing.”

Pro Tip: Always use .join() if your program depends on the thread’s result, or if you don’t want your script to exit before the thread finishes its work.

If you’re not yet comfortable with how functions and arguments work in Python, it may help to first check out how to take user input in Python, which covers some foundational syntax used throughout this guide.


Running Multiple Threads

In practice, you’ll usually want to run several threads at once, not just one. Here’s a practical example: imagine you want to “download” three files at the same time (we’ll simulate the download with time.sleep()).

import threading
import time

def download_file(file_name, delay):
    print(f"Starting download: {file_name}")
    time.sleep(delay)
    print(f"Finished download: {file_name}")

files = [
    ("report.pdf", 2),
    ("image.png", 3),
    ("data.csv", 1)
]

threads = []

for file_name, delay in files:
    t = threading.Thread(target=download_file, args=(file_name, delay))
    threads.append(t)
    t.start()

# Wait for all threads to finish
for t in threads:
    t.join()

print("All downloads complete!")

What You’ll Notice

If you run this, you’ll see that “Finished download: data.csv” likely prints before the other two, even though it was listed last — because it has the shortest delay. This is the essence of concurrency: tasks don’t necessarily finish in the order they started.

Beginner Warning: The order of output from multiple threads is not guaranteed. Don’t write code that depends on threads finishing in a specific order unless you explicitly enforce it (using join(), locks, or other synchronization tools).

This kind of pattern — running multiple independent tasks concurrently — is exactly how tools like automating WhatsApp messages using Python or bulk email scripts can send multiple messages without waiting for each one individually.


Understanding the Global Interpreter Lock (GIL)

This is the part of multithreading that confuses almost every Python beginner — so let’s take it slowly.

What is the GIL?

The Global Interpreter Lock (GIL) is a mechanism in CPython (the standard Python implementation) that ensures only one thread executes Python bytecode at a time, even if your computer has multiple CPU cores.

Yes, you read that right. Even if you create 10 threads, only one of them is actively running Python code at any given instant in the traditional CPython setup.

Why Does the GIL Exist?

The GIL exists primarily because of how CPython manages memory internally — specifically, reference counting. Every Python object keeps track of how many references point to it, and this counter needs to be updated safely. Without some form of locking, multiple threads updating this counter at the same time could cause memory corruption or crashes.

If you want to understand more about how Python manages objects and memory behind the scenes, our guide on how Python handles memory is a great next read.

What Does This Mean in Practice?

Task TypeDoes Multithreading Help?
I/O-bound (network requests, file I/O, waiting)✅ Yes — significantly
CPU-bound (heavy calculations, image processing, math loops)❌ Usually not — the GIL becomes a bottleneck

Here’s why: when a thread performs an I/O operation (like waiting for a network response), Python releases the GIL, allowing another thread to run. But for pure CPU computation, threads constantly fight over the GIL, leading to little or no speedup — sometimes even a slowdown due to overhead.

Key Takeaway: If your task is mostly waiting (for files, networks, APIs), multithreading helps a lot. If your task is mostly crunching numbers, multithreading alone usually won’t make it faster — you’ll want multiprocessing instead (covered next).

The 2026 Update: Free-Threaded Python (No More GIL?)

Here’s something genuinely new and worth knowing about as a 2026 Python learner.

Starting with Python 3.13, and made officially supported in Python 3.14, CPython introduced an optional free-threaded build — sometimes written as python3.14t — where the GIL can be disabled.

In this free-threaded mode:

  • Multiple threads can truly run Python bytecode in parallel across CPU cores
  • CPU-bound multithreaded code can see real speedups — sometimes scaling close to linearly with the number of threads
  • However, single-threaded code may run slightly slower (roughly 5–10%) due to extra safety checks
  • Some third-party libraries with C extensions may not yet support free-threading and could silently re-enable the GIL

Should beginners use free-threaded Python right now? Not as your default. The standard (GIL-enabled) build is still the default for a reason — it’s stable, and most libraries are built and tested against it. Free-threaded Python is officially supported, but the ecosystem (third-party packages) is still catching up. As a beginner, it’s enough to understand that this exists and is the direction Python is heading. If you’re curious, you can experiment with python3.14t on a test project — just don’t build production systems on it yet without thorough testing.


Multithreading vs Multiprocessing

This is one of the most common points of confusion — and one of the most common People Also Ask questions.

FeatureMultithreadingMultiprocessing
Best forI/O-bound tasks (network, file, DB)CPU-bound tasks (math, image/video processing)
MemoryThreads share memoryEach process has its own memory
GIL impactLimited by GIL (in standard builds)Not affected — each process has its own interpreter
OverheadLower (lightweight)Higher (each process is heavier)
CommunicationEasy (shared variables)Harder (needs pipes, queues, etc.)
Crash isolationOne thread crash can affect the whole processOne process crash doesn’t affect others
Modulethreadingmultiprocessing

Simple Rule of Thumb

  • Waiting a lot? → Use threading.
  • Calculating a lot? → Use multiprocessing.
  • Both, and you’re comfortable with more complexity? → Consider asyncio for I/O, combined with multiprocessing for heavy computation.

If you’re building something like a small web application and wondering how concurrency fits in, our guide on building a simple website using Flask shows how a real Python web framework handles multiple requests — which connects directly to these concurrency concepts.


Thread Lifecycle

Every thread in Python goes through a series of stages, often called the thread lifecycle:

  1. New – The thread object has been created (threading.Thread(...)) but .start() hasn’t been called yet.
  2. Runnable.start() has been called, and the thread is ready to run (waiting for CPU/GIL).
  3. Running – The thread is actively executing code.
  4. Blocked/Waiting – The thread is paused, often waiting for I/O, a lock, or another thread (via .join()).
  5. Terminated – The thread has finished executing (its target function has returned).

Here’s a small example that prints the thread’s status at different points:

import threading
import time

def task():
    print("Thread is running...")
    time.sleep(1)
    print("Thread is finishing...")

t = threading.Thread(target=task)

print("Before start - is_alive:", t.is_alive())  # False (New)

t.start()
print("After start - is_alive:", t.is_alive())   # True (Running)

t.join()
print("After join - is_alive:", t.is_alive())    # False (Terminated)

Note: Once a thread is terminated, it cannot be restarted. If you need to run the same task again, you must create a new Thread object.


Daemon Threads

A daemon thread is a special type of thread that runs in the background and is automatically killed when the main program exits — even if it hasn’t finished its task.

When Are Daemon Threads Useful?

  • Background logging
  • Periodic health checks
  • Monitoring tasks that should stop when the main program stops

Example

import threading
import time

def background_task():
    while True:
        print("Running in background...")
        time.sleep(1)

# Create a daemon thread
t = threading.Thread(target=background_task, daemon=True)
t.start()

print("Main program doing some work...")
time.sleep(3)
print("Main program finished. Daemon thread will be killed now.")

When the main program ends after 3 seconds, the daemon thread (which would otherwise run forever) is automatically stopped — no manual cleanup needed.

Beginner Warning: Don’t use daemon threads for tasks that absolutely must complete (like saving data to a file or database). Since daemon threads can be killed abruptly, this can lead to incomplete writes or data loss. For critical tasks, use regular (non-daemon) threads and .join() to ensure they finish properly.


Thread Synchronization

This is where multithreading gets a little tricky — but it’s essential to understand if you want to write safe, bug-free concurrent code.

The Problem: Race Conditions

A race condition happens when two or more threads access and modify shared data at the same time, leading to unpredictable results.

import threading

counter = 0

def increment():
    global counter
    for _ in range(100000):
        counter += 1

threads = []
for _ in range(2):
    t = threading.Thread(target=increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Final counter value:", counter)

You might expect the result to always be 200000, but due to a race condition, it might print something less than that. Why? Because counter += 1 isn’t a single atomic operation — multiple threads can read the same value, increment it, and write it back, overwriting each other’s progress.

This is exactly the kind of subtle bug that’s hard to spot but easy to cause — and a great example to walk through using how to debug Python code step by step if you ever run into unexpected values like this in your own code.

Solution 1: Lock

A Lock ensures that only one thread can execute a certain block of code at a time.

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

threads = []
for _ in range(2):
    t = threading.Thread(target=increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Final counter value:", counter)

Now, counter will reliably equal 200000, because the with lock: block ensures only one thread modifies counter at a time.

Pro Tip: Always prefer with lock: over manually calling lock.acquire() and lock.release(). The with statement automatically releases the lock even if an error occurs — preventing deadlocks.

Solution 2: RLock (Reentrant Lock)

An RLock (Re-entrant Lock) is similar to a regular Lock, but it can be acquired multiple times by the same thread without blocking itself.

import threading

rlock = threading.RLock()

def outer():
    with rlock:
        print("Outer function acquired the lock")
        inner()

def inner():
    with rlock:
        print("Inner function also acquired the lock (same thread)")

t = threading.Thread(target=outer)
t.start()
t.join()

With a regular Lock, this would cause a deadlock — the thread would be stuck waiting for a lock it already holds. RLock solves this by keeping track of how many times the same thread has acquired it.

Solution 3: Semaphore

A Semaphore limits how many threads can access a resource at the same time — useful for things like limiting concurrent API calls or database connections.

import threading
import time

semaphore = threading.Semaphore(2)  # Only 2 threads allowed at once

def access_resource(thread_id):
    with semaphore:
        print(f"Thread {thread_id} is using the resource")
        time.sleep(2)
        print(f"Thread {thread_id} is done")

for i in range(5):
    threading.Thread(target=access_resource, args=(i,)).start()

Even though 5 threads start, only 2 at a time can be inside the with semaphore: block — the rest wait their turn.

Solution 4: Event

An Event is a simple way for one thread to signal other threads that something has happened.

import threading
import time

event = threading.Event()

def waiter():
    print("Waiting for the event to be set...")
    event.wait()  # Blocks until event.set() is called
    print("Event received! Continuing...")

def setter():
    time.sleep(3)
    print("Setting the event now.")
    event.set()

threading.Thread(target=waiter).start()
threading.Thread(target=setter).start()

This pattern is common when one thread needs to wait for another to finish setup, load data, or signal “ready.”


Common Multithreading Mistakes Beginners Make

  1. Expecting CPU-bound speedups. Multithreading doesn’t speed up heavy computation in standard Python builds due to the GIL. Use multiprocessing instead.
  2. Forgetting .join(). Without it, your main program might exit before threads finish, especially for non-daemon threads with pending work.
  3. Sharing data without synchronization. Modifying shared variables from multiple threads without a Lock leads to race conditions and unpredictable bugs.
  4. Creating too many threads. Each thread has memory and scheduling overhead. Hundreds of threads for a small task can actually slow down your program.
  5. Using daemon threads for critical work. As mentioned earlier, daemon threads can be killed mid-task — never rely on them for saving data.
  6. Mixing print statements without synchronization. Output from multiple threads can interleave in confusing ways. This isn’t usually a bug, but it can make debugging harder.
  7. Holding locks for too long. Only lock the smallest section of code necessary — holding a lock during slow operations (like network calls) defeats the purpose of using multiple threads.

Pro Tip: If you’re new to Python in general and still getting comfortable with basics like indentation and syntax, multithreading bugs can be extra confusing. It’s worth reviewing how to fix IndentationError in Python so that syntax issues don’t get mixed up with concurrency issues while you’re debugging.


Real-World Use Cases

Let’s look at where multithreading genuinely shines in real Python projects.

1. File Processing

If you need to process multiple files (resize images, parse logs, convert formats), multithreading lets you handle several files concurrently, especially when reading/writing from disk.

2. Web Scraping

Fetching multiple web pages is a classic I/O-bound task. Instead of requesting one page, waiting, then requesting the next, you can fire off multiple requests concurrently using threads — dramatically cutting total wait time.

3. API Requests

Similar to web scraping — if your script calls multiple external APIs (weather data, currency rates, etc.), multithreading lets those requests happen in parallel rather than sequentially.

4. Background Tasks

Things like periodic checks, logging, or monitoring system resources can run in a background (often daemon) thread while your main program does other work.

5. Automation Scripts

Many automation tasks — like checking multiple folders, sending notifications, or running scheduled jobs — benefit from running independent tasks concurrently. If you’re building automation tools, our guide on automating daily tasks on your computer using Python pairs well with multithreading concepts covered here.

6. Sending Bulk Notifications

Sending multiple emails or messages (e.g., reminders to a list of users) is another great fit. See how to send emails automatically using Python for a base script you could enhance with threading to send multiple emails concurrently.


Performance Considerations

When deciding whether multithreading is the right tool, ask yourself:

  • Is my task I/O-bound or CPU-bound?
    • I/O-bound → threading is a great fit
    • CPU-bound → consider multiprocessing or free-threaded Python (3.13+/3.14)
  • How many threads do I actually need?
    • More threads ≠ more speed. There’s a point where adding threads adds overhead without benefit. Start small (e.g., 5–10) and measure.
  • Am I using the right tool for the job?
    • For a large number of I/O-bound tasks, concurrent.futures.ThreadPoolExecutor is often easier to manage than creating raw threads manually:
from concurrent.futures import ThreadPoolExecutor
import time

def fetch(item):
    time.sleep(1)
    return f"Fetched {item}"

items = ["A", "B", "C", "D", "E"]

with ThreadPoolExecutor(max_workers=3) as executor:
    results = executor.map(fetch, items)

for result in results:
    print(result)

ThreadPoolExecutor automatically manages a pool of worker threads, reusing them efficiently — generally cleaner and safer than manually creating and joining threads for larger workloads.

Note: Always measure before and after adding threads. Use Python’s time module or a proper profiler to confirm multithreading is actually helping your specific use case.


Best Practices for Python Multithreading in 2026

  1. Use ThreadPoolExecutor for most I/O-bound workloads instead of manually managing Thread objects — it’s simpler and less error-prone.
  2. Always synchronize shared data using Lock, RLock, or thread-safe data structures like queue.Queue.
  3. Avoid CPU-bound multithreading on standard Python builds — use multiprocessing, or consider testing on Python 3.14’s free-threaded build (python3.14t) for genuinely parallel CPU work, after checking library compatibility.
  4. Keep critical sections small. Only wrap the minimum code necessary inside a with lock: block.
  5. Use daemon threads only for non-critical background work — never for tasks involving unsaved data.
  6. Always call .join() when your program depends on a thread completing, or when you want to ensure clean shutdown.
  7. Test thread-safety deliberately — run your code multiple times, and under load, to catch race conditions that might not appear on the first run.
  8. Keep learning the fundamentals. A solid grasp of object-oriented programming in Python makes it much easier to design clean, subclassed Thread objects for larger projects.

Frequently Asked Questions

What is multithreading in Python with example?

Multithreading in Python means running multiple threads (smaller units of a program) concurrently within the same process. A simple example is using threading.Thread(target=function_name) to run a function in the background while the main program continues executing other code.

Is Python good for multithreading?

Python’s threading module is excellent for I/O-bound tasks like network requests, file operations, and API calls. For CPU-bound tasks, traditional Python multithreading is limited by the GIL, so multiprocessing (or Python 3.14’s free-threaded build) is usually a better choice.

What is the difference between multithreading and multiprocessing in Python?

Multithreading runs multiple threads within a single process that share memory, making it ideal for I/O-bound tasks. Multiprocessing runs separate processes with their own memory, making it better suited for CPU-bound tasks that need true parallel computation.

What is the GIL in Python and why does it exist?

The Global Interpreter Lock (GIL) is a mechanism in CPython that allows only one thread to execute Python bytecode at a time. It exists mainly to keep Python’s internal memory management (reference counting) safe from conflicts between threads.

Does Python 3.14 remove the GIL?

Python 3.14 officially supports a free-threaded build (PEP 703/PEP 779) where the GIL can be disabled, allowing true parallel thread execution. However, the standard build still uses the GIL by default, and full ecosystem compatibility with free-threading is still developing as of 2026.

How do you create a thread in Python?

You create a thread using threading.Thread(target=your_function, args=(...)), then call .start() to begin execution and .join() to wait for it to finish.

What are daemon threads used for in Python?

Daemon threads run in the background and are automatically terminated when the main program exits. They’re useful for non-critical tasks like logging or monitoring, but shouldn’t be used for tasks that must complete fully, like saving data.

How do you prevent race conditions in Python threads?

You prevent race conditions by using synchronization tools like Lock, RLock, or Semaphore to ensure that only one thread modifies shared data at a time, or by using thread-safe data structures like queue.Queue.

When should I use threading vs asyncio in Python?

Use threading when working with libraries that perform blocking I/O operations (and don’t support async natively). Use asyncio when you’re working with async-compatible libraries and want to manage thousands of lightweight concurrent tasks with lower overhead.

Is multithreading still relevant in Python in 2026?

Yes. Multithreading remains highly relevant for I/O-bound tasks like web scraping, API calls, and file operations. With Python 3.13/3.14’s free-threaded builds becoming officially supported, multithreading is also becoming increasingly relevant for CPU-bound tasks as the ecosystem matures.


Conclusion

Multithreading in Python doesn’t have to be intimidating. At its core, it’s about letting your program do multiple things “at once” — especially useful when your code spends a lot of time waiting rather than computing.

Here’s a quick recap:

  • Threads share memory within a process, making communication easy but requiring careful synchronization.
  • The GIL limits true parallel execution of Python bytecode in standard builds — great for I/O-bound tasks, less so for CPU-bound tasks.
  • Python 3.13/3.14’s free-threaded builds are changing this picture, but the ecosystem is still catching up — beginners should understand this trend without rushing to adopt it in production.
  • Tools like Lock, RLock, Semaphore, and Event help you write safe, predictable concurrent code.
  • For most beginner and intermediate projects, ThreadPoolExecutor combined with proper synchronization is the sweet spot.

The best way to truly understand multithreading is to experiment. Try modifying the examples in this guide — change delays, add more threads, remove a lock and see what happens (in a safe test environment!).

If you want to keep building your Python foundations, check out our guides on Python decorators made simple and Python generators vs iterators — both pair well with concurrency concepts as you grow as a developer. And if you’re preparing for interviews, our Top 20 Python interview questions for beginners (2026) covers multithreading and the GIL as well.


References & Further Reading

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *