Python Threads: The Basics

Have you ever wanted your Python program to do two things at the same time — like download cat photos and play your favorite tune in the background? That’s exactly what threads are for.

In Python, threads let your program multitask — kind of like a circus juggler handling flaming swords while riding a unicycle. They allow different parts of your code to run at the same time, making your programs more interactive, responsive, and a lot more fun to work with.

In this article, we’ll explore how to use threads in Python with fun and practical examples — nothing too serious, just pure how-to goodness. Ready to let your code juggle? Let’s dive in.

Getting Started with Threads

Before your Python program can truly multitask, it needs to learn how to spin up threads. A thread is like giving your code a helpful assistant — someone who can go off and do a small job while the rest of the program keeps moving. It’s not as complicated as it might sound. In fact, it only takes a few lines to get started.

Importing the Right Tools

To begin, you’ll need the threading module. This is Python’s built-in toolkit for working with threads. No need to install anything — just bring it in with a simple:

import threading

Once imported, you have access to everything you need to create and manage threads. You can think of this module as the command center that organizes your helpers and tells them what to do.

Your First Thread

Let’s write a tiny program that creates a thread. This thread will do just one thing: say “Hi” from the background.

import threading

def say_hi():
    print("Hi from a thread!")

# Create a thread and tell it what function to run
t = threading.Thread(target=say_hi)

# Start the thread — this tells it to begin working
t.start()

# Wait for the thread to finish before moving on
t.join()

In this example, you start by writing a function called say_hi(). This is the job your thread will perform. After that, you create a thread object using threading.Thread, and you tell it to run the say_hi function when it starts.

Next, you call start() on the thread to kick it into action. The thread begins running on its own, separate from the main part of your program. Lastly, join() is called to tell your main program, “Wait here until this thread finishes its work.” This way, the thread gets a chance to complete its task before the program exits.

Just like that, you’ve created a basic thread! It didn’t do anything fancy, but it ran independently — that’s the whole point. Your Python code is now ready to handle multiple tasks at once.

Now that you’ve seen how to launch a single thread, let’s go a step further. What if you had a whole team of threads working together, each doing something different — like calling out animal names or preparing breakfast? That’s where things get fun. Let’s look at running multiple threads next.

Running Multiple Threads

So you’ve seen one thread in action. But threads become really useful when you run several of them — all doing different jobs at the same time. This lets your program multitask, like a group of playful puppies each chasing a different ball.

Let’s build a fun example where each thread calls out a different animal, and they all run together without blocking or waiting for one another.

import threading
import time

# This function will run in each thread
def call_animal(name):

    for i in range(3):
        print(f"{name} is here!")  # Print the animal's name
        time.sleep(1)              # Wait 1 second (pretend it’s doing work)

# List of animal names to be called
animals = ["Elephant", "Zebra", "Giraffe"]

# Create and start a thread for each animal
for animal in animals:
    # Pass the function and its argument to the thread
    t = threading.Thread(target=call_animal, args=(animal,))
    t.start()  # Start the thread (it runs on its own)

In this example, we create a function that prints an animal’s name three times with a short pause between each. Then we loop through a list of animals and start a new thread for each one. As a result, all the animals begin calling out at nearly the same time, each in its own thread. Since we don’t use join() here, the threads don’t wait for each other — they just run freely. This shows how threads can work in parallel, letting your program do multiple things at once without blocking.

Next, we’ll explore a slightly different way of creating threads — by using classes. It’s great when you want your threads to be a bit more organized. Ready to see how it works? Let’s move on.

Using Thread Classes

Sometimes, you want your threads to be a bit smarter and more organized. Instead of just passing a function to a thread, you can create a class that represents a thread with its own behavior. This is the object-oriented way to work with threads in Python.

To do this, you make a new class that inherits from threading.Thread. Inside your class, you define a special method called run(). This method holds the code your thread will execute when it starts.

Here’s a simple example: a thread class that prints a friendly greeting with a name.

import threading

class Greeter(threading.Thread):

    def __init__(self, name):
        super().__init__()     # Call the original thread setup
        self.name = name       # Store the name to use later

    def run(self):
        print(f"Hello from {self.name}!")

# Create and start a Greeter thread
greeter = Greeter("Lucia")
greeter.start()

In this code, the Greeter class is a thread that knows its own name. When you create a Greeter object and call start(), Python runs its run() method automatically. This prints the greeting, like “Hello from Lucia!”.

Using thread classes is handy when your thread needs to keep track of information or do more complex tasks. It also keeps your code neat and easy to manage.

Waiting for Threads (join)

When you start a bunch of threads, they run independently — sometimes too independently! Your main program might finish before all the threads are done. To keep everything neat and make sure all threads complete their work before the program ends, you use .join().

Think of .join() as telling your main program, “Hold on, wait for this thread to finish before moving on.” It’s like putting together puzzle pieces: you want to make sure each piece fits before you say the puzzle is complete.

Here’s an example where three threads place puzzle pieces in order. We start each thread, then use .join() to wait for all of them to finish. Only after all pieces are placed do we print “Puzzle complete!”

import threading

def piece(name):
    print(f"{name} piece placed!")  # Announce when a piece is placed

threads = []

for name in ['Top', 'Middle', 'Bottom']:
    t = threading.Thread(target=piece, args=(name,))
    t.start()           # Start each thread
    threads.append(t)    # Keep track of the thread

for t in threads:
    t.join()            # Wait here until the thread finishes

print("Puzzle complete!")  # All threads done, puzzle is done

Without .join(), your program might say “Puzzle complete!” before all the pieces are actually placed. Using .join() makes sure the threads finish their jobs properly, keeping your program organized and smooth.

Named Threads (Optional Naming)

Sometimes it helps to give your threads names, especially when you have many of them running. Naming threads is like giving each helper a badge with their name on it. This way, when your program prints messages, you know exactly which thread is talking.

You can set a thread’s name when you create it, and then use that name inside the thread’s code. This makes debugging and tracking much easier — like following the footsteps of “Detective Fred” on a case.

Here’s a small example where a thread introduces itself by name:

import threading

def investigate():
    # Print the name of the current thread on the job
    print(f"{threading.current_thread().name} is on the case!")

# Create a thread with a custom name
t = threading.Thread(target=investigate, name="Detective Fred")
t.start()
t.join()

When this runs, the thread announces itself by name, making it clear who’s doing the work. Naming threads adds personality to your code and helps you keep track of what’s happening behind the scenes.

Daemon Threads (Background Tasks)

Some threads are like background helpers that quietly do their job without getting in the way. These are called daemon threads. They run behind the scenes and don’t stop your program from finishing. When the main program ends, daemon threads quietly disappear—like friendly ghosts fading away.

Daemon threads are perfect for tasks that should keep running but don’t need to block your program from exiting. For example, a background music player, a status checker, or a watcher waiting for something to happen.

Here’s a fun example of a “ghost” thread that sings softly in the background while the main program runs:

import threading
import time

def ghost():

    while True:
        print("🎵 Woooo... I'm a background thread 🎵")  # Ghost keeps singing
        time.sleep(1)  # Pause for a second

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

# Main thread sleeps for 3 seconds, letting the ghost sing
time.sleep(3)

print("Main thread exits. Ghost fades...")

In this code, the ghost thread runs an endless loop, singing every second. But because it’s a daemon, it won’t stop the main program from exiting. After three seconds, the main thread ends, and the ghost thread quietly fades away.

Daemon threads are great when you want background tasks that don’t hold up your program. However, depending on how your Python environment handles output and process termination, the program might appear to keep running after the last line.

Managing Shared Data Safely with Locks

When threads share information — like keeping track of a score or counting points — they can accidentally mess up if two threads try to change the data at the same time. This problem is called a race condition. To avoid this, Python gives us something called a lock. A lock acts like a turnstile, letting only one thread update the data at a time.

Here’s a playful example where two threads try to add points to the same scoreboard. Without a lock, the score could get mixed up. But with a lock, each thread waits its turn, so the score stays correct.

import threading
import time

score = 0  # Shared data
lock = threading.Lock()  # Create a lock

def add_points(name):

    global score

    for _ in range(5):

        with lock:  # Only one thread can do this block at a time
            local_score = score
            local_score += 1
            time.sleep(0.1)  # Simulate some delay
            score = local_score
            print(f"{name} added a point. Score is now {score}")

# Create two threads adding points at the same time
t1 = threading.Thread(target=add_points, args=("Fred",))
t2 = threading.Thread(target=add_points, args=("George",))

t1.start()
t2.start()
t1.join()
t2.join()

print(f"Final score: {score}")

In this example, the lock makes sure that only one thread changes the score at a time. If Fred is adding a point, George waits his turn. This keeps the score safe and accurate — no mix-ups allowed.

Locks are simple but powerful tools that help threads share data without chaos. They keep your program running smoothly, even when many threads are playing together.

Real-World Mini Project: Multi-threaded Countdown Timer

Let’s bring everything together with a simple real-world project — a multi-threaded countdown timer. Imagine you want to run two timers at once: one for a quick Pomodoro work session and another for a short break. Using threads, both timers can count down side by side, without waiting for each other.

In this example, each timer runs in its own thread. The countdown function prints the remaining seconds every second until it reaches zero. Because the timers run in parallel threads, you’ll see their countdowns mixed together on the screen, just like a busy kitchen where multiple dishes cook at the same time.

import threading
import time

def countdown(name, secs):

    while secs:
        print(f"{name}: {secs} seconds left")
        time.sleep(1)
        secs -= 1

    print(f"{name} done!")

timers = [('Pomodoro', 3), ('Break', 2)]

for name, secs in timers:
    t = threading.Thread(target=countdown, args=(name, secs))
    t.start()

This little project shows how threads let your program handle multiple tasks at once, making your timers run smoothly side by side. It’s a fun way to see threading in action!

Conclusion

In this article, we explored the basics of Python threads. We learned how to start threads using functions and classes, how to wait for threads to finish with .join(), and how to give threads names to keep track of them. We also saw how daemon threads run quietly in the background without holding up your program. Along the way, fun examples like animal calls, countdown timers, and background ghost threads helped bring these ideas to life.

Threads are a powerful way to make your programs multitask and run smoothly. Now that you know the basics, you can explore more advanced topics like writing thread-safe code or using queues to share data safely. Keep experimenting, and enjoy the world of threading.