Skip to content

Simple Stopwatch

Abstract

A stopwatch is a deceptively small project — under 50 lines and you have a working app. Behind those lines sit two important Tkinter concepts: the event loop and scheduled callbacks via afterafter. We will start with the naïve counter-variable version (which drifts), fix it with time.time()time.time() for true elapsed-time accuracy, then add HH:MM

formatting, lap times, keyboard shortcuts, persistence between sessions, and a countdown mode.

You will leave understanding:

  • Why a recurring after(1000, fn)after(1000, fn) is not a real 1-second timer.
  • The right way to measure elapsed time (record a start timestamp; subtract from time.time()time.time()).
  • How to format seconds as HH:MM:SSHH:MM:SS.
  • The lap-time pattern.
  • How to bind keyboard shortcuts to Tkinter buttons.

Prerequisites

  • Python 3.6 or above.
  • A text editor or IDE.
  • Tkinter (ships with Python; Linux: sudo apt install python3-tksudo apt install python3-tk).
  • Familiarity with the Tkinter event loop. Basic Music Player is a softer first GUI project.

Getting Started

Create the project

  1. Create folder simple-stopwatchsimple-stopwatch.
  2. Inside, create simplestopwatch.pysimplestopwatch.py.

Write the code

Simple StopwatchSource
Simple Stopwatch
# Simple Stopwatch
 
# Import Modules
import time
import datetime
import tkinter as tk
 
# Defining Variables
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("500x500")
root.resizable(False, False)
root.config(bg="black")
 
# Defining Functions
def start():
    global running
    running = True
    global count
    count = -1
    counter()
    
def counter():
    global running
    global count
    if running:
        count += 1
        time_label.config(text=str(count))
        time_label.after(1000, counter)
        
def stop():
    global running
    running = False
    
def reset():
    global count
    count = 0
    time_label.config(text=str(count))
    
# Creating Widgets
time_label = tk.Label(root, text="0", font=("Helvetica", 80), bg="black", fg="white")
 
start_button = tk.Button(root, text="Start", font=("Helvetica", 20), bg="black", fg="white", command=start)
stop_button = tk.Button(root, text="Stop", font=("Helvetica", 20), bg="black", fg="white", command=stop)
reset_button = tk.Button(root, text="Reset", font=("Helvetica", 20), bg="black", fg="white", command=reset)
 
# Placing Widgets
time_label.pack(pady=20)
start_button.pack(pady=20)
stop_button.pack(pady=20)
reset_button.pack(pady=20)
 
# Calling Functions
root.mainloop()
Simple Stopwatch
# Simple Stopwatch
 
# Import Modules
import time
import datetime
import tkinter as tk
 
# Defining Variables
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("500x500")
root.resizable(False, False)
root.config(bg="black")
 
# Defining Functions
def start():
    global running
    running = True
    global count
    count = -1
    counter()
    
def counter():
    global running
    global count
    if running:
        count += 1
        time_label.config(text=str(count))
        time_label.after(1000, counter)
        
def stop():
    global running
    running = False
    
def reset():
    global count
    count = 0
    time_label.config(text=str(count))
    
# Creating Widgets
time_label = tk.Label(root, text="0", font=("Helvetica", 80), bg="black", fg="white")
 
start_button = tk.Button(root, text="Start", font=("Helvetica", 20), bg="black", fg="white", command=start)
stop_button = tk.Button(root, text="Stop", font=("Helvetica", 20), bg="black", fg="white", command=stop)
reset_button = tk.Button(root, text="Reset", font=("Helvetica", 20), bg="black", fg="white", command=reset)
 
# Placing Widgets
time_label.pack(pady=20)
start_button.pack(pady=20)
stop_button.pack(pady=20)
reset_button.pack(pady=20)
 
# Calling Functions
root.mainloop()

Run it

command
C:\Users\Your Name\simple-stopwatch> python simplestopwatch.py
# Window with big "0", Start / Stop / Reset buttons
command
C:\Users\Your Name\simple-stopwatch> python simplestopwatch.py
# Window with big "0", Start / Stop / Reset buttons

Step-by-Step Explanation

1. Imports and root window

simplestopwatch.py
import tkinter as tk
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("400x300")
root.config(bg="black")
simplestopwatch.py
import tkinter as tk
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("400x300")
root.config(bg="black")

2. State and label

simplestopwatch.py
running = False
count = 0
label = tk.Label(root, text="0", font=("Helvetica", 80), bg="black", fg="white")
label.pack(pady=20)
simplestopwatch.py
running = False
count = 0
label = tk.Label(root, text="0", font=("Helvetica", 80), bg="black", fg="white")
label.pack(pady=20)

3. The naïve counter loop

simplestopwatch.py
def tick():
    global count
    if running:
        count += 1
        label.config(text=str(count))
        label.after(1000, tick)        # schedule the next call
 
def start():
    global running
    if not running:
        running = True
        tick()
 
def stop():
    global running
    running = False
 
def reset():
    global count
    count = 0
    label.config(text="0")
simplestopwatch.py
def tick():
    global count
    if running:
        count += 1
        label.config(text=str(count))
        label.after(1000, tick)        # schedule the next call
 
def start():
    global running
    if not running:
        running = True
        tick()
 
def stop():
    global running
    running = False
 
def reset():
    global count
    count = 0
    label.config(text="0")

after(ms, fn)after(ms, fn) tells Tkinter “call fnfn after msms milliseconds during the event loop.” That is how you do “do something every second” without blocking the UI.

4. Wire the buttons

simplestopwatch.py
for txt, cmd in [("Start", start), ("Stop", stop), ("Reset", reset)]:
    tk.Button(root, text=txt, font=("Helvetica", 18),
              bg="black", fg="white", command=cmd).pack(pady=10)
 
root.mainloop()
simplestopwatch.py
for txt, cmd in [("Start", start), ("Stop", stop), ("Reset", reset)]:
    tk.Button(root, text=txt, font=("Helvetica", 18),
              bg="black", fg="white", command=cmd).pack(pady=10)
 
root.mainloop()

The Drift Problem

The naïve version is wrong. Two reasons:

  1. Tkinter’s afterafter is not a precise scheduler. A second’s worth of wall-clock might be 1.01s on a busy laptop. After a minute you have drifted by half a second.
  2. countcount measures “ticks fired”, not elapsed time. If the UI is sluggish, ticks pile up — or skip.

Fix it by recording a start timestamp and computing elapsed time from time.time()time.time():

fixed.py
import time
start_time = 0.0
elapsed_at_pause = 0.0
running = False
 
def tick():
    if running:
        elapsed = (time.time() - start_time) + elapsed_at_pause
        label.config(text=format_time(elapsed))
        label.after(50, tick)    # 50 ms = 20 fps; smooth display, irrelevant to accuracy
 
def start():
    global running, start_time
    if not running:
        running = True
        start_time = time.time()
        tick()
 
def stop():
    global running, elapsed_at_pause
    if running:
        elapsed_at_pause += time.time() - start_time
        running = False
 
def reset():
    global running, elapsed_at_pause
    running = False
    elapsed_at_pause = 0
    label.config(text="00:00.0")
fixed.py
import time
start_time = 0.0
elapsed_at_pause = 0.0
running = False
 
def tick():
    if running:
        elapsed = (time.time() - start_time) + elapsed_at_pause
        label.config(text=format_time(elapsed))
        label.after(50, tick)    # 50 ms = 20 fps; smooth display, irrelevant to accuracy
 
def start():
    global running, start_time
    if not running:
        running = True
        start_time = time.time()
        tick()
 
def stop():
    global running, elapsed_at_pause
    if running:
        elapsed_at_pause += time.time() - start_time
        running = False
 
def reset():
    global running, elapsed_at_pause
    running = False
    elapsed_at_pause = 0
    label.config(text="00:00.0")

Now the displayed time is always accurate to within one frame, regardless of how laggy the UI is.

Format Time as HH:MM
.t

format.py
def format_time(seconds: float) -> str:
    h, rem = divmod(int(seconds), 3600)
    m, s = divmod(rem, 60)
    tenths = int((seconds - int(seconds)) * 10)
    return f"{h:02d}:{m:02d}:{s:02d}.{tenths}"
format.py
def format_time(seconds: float) -> str:
    h, rem = divmod(int(seconds), 3600)
    m, s = divmod(rem, 60)
    tenths = int((seconds - int(seconds)) * 10)
    return f"{h:02d}:{m:02d}:{s:02d}.{tenths}"

divmod(a, b)divmod(a, b) returns (a // b, a % b)(a // b, a % b) in one call — the standard Python idiom for time-component math.

Lap Times

A lap button records the current elapsed time without resetting:

laps.py
laps: list[float] = []
laps_list = tk.Listbox(root, height=8)
laps_list.pack(fill="x", padx=20)
 
def add_lap():
    if not running: return
    elapsed = (time.time() - start_time) + elapsed_at_pause
    laps.append(elapsed)
    n = len(laps)
    diff = elapsed - (laps[-2] if n > 1 else 0)
    laps_list.insert("end", f"Lap {n}: {format_time(elapsed)}  (+{format_time(diff)})")
 
tk.Button(root, text="Lap", command=add_lap).pack(pady=4)
laps.py
laps: list[float] = []
laps_list = tk.Listbox(root, height=8)
laps_list.pack(fill="x", padx=20)
 
def add_lap():
    if not running: return
    elapsed = (time.time() - start_time) + elapsed_at_pause
    laps.append(elapsed)
    n = len(laps)
    diff = elapsed - (laps[-2] if n > 1 else 0)
    laps_list.insert("end", f"Lap {n}: {format_time(elapsed)}  (+{format_time(diff)})")
 
tk.Button(root, text="Lap", command=add_lap).pack(pady=4)

Each entry shows both the total elapsed and the split from the previous lap.

Keyboard Shortcuts

Make Space toggle, R reset, L lap:

keys.py
def toggle(_event=None):
    stop() if running else start()
 
root.bind("<space>", toggle)
root.bind("<r>", lambda e: reset())
root.bind("<l>", lambda e: add_lap())
keys.py
def toggle(_event=None):
    stop() if running else start()
 
root.bind("<space>", toggle)
root.bind("<r>", lambda e: reset())
root.bind("<l>", lambda e: add_lap())

Common Mistakes

ProblemCauseFix
Drift after 5 minutesCounted ticks, not elapsed timeUse time.time()time.time() reference
Multiple tick()tick() chains spawn after rapid Start clicksNo guard on start()start()if not running:if not running: before scheduling
Window freezes on heavy workDid blocking work in callbackKeep callbacks fast; use threads for blocking ops
Reset while running leaves stale tick scheduledDid not stop the chainSet running = Falserunning = False before resetting
Tenths jitter visiblyRefreshed every 1000 msRefresh every 50–100 ms; format with fractional seconds
Numbers misalignedVariable-width fontUse a monospace font (ConsolasConsolas, CourierCourier)

Variations to Try

1. Countdown timer

countdown.py
duration = 5 * 60                 # 5 minutes
def tick():
    remaining = duration - ((time.time() - start_time) + elapsed_at_pause)
    if remaining <= 0:
        label.config(text="00:00.0")
        notify("Time!")
        return
    label.config(text=format_time(remaining))
    label.after(50, tick)
countdown.py
duration = 5 * 60                 # 5 minutes
def tick():
    remaining = duration - ((time.time() - start_time) + elapsed_at_pause)
    if remaining <= 0:
        label.config(text="00:00.0")
        notify("Time!")
        return
    label.config(text=format_time(remaining))
    label.after(50, tick)

Combines neatly with Basic Alarm Clock.

2. Pomodoro mode

25 minutes of work, 5 minutes break, repeat. Toggle a phasephase state and chain timers.

3. Save lap history

JSON file with timestamp + laps list. Reload on next launch.

4. CSV export

“Export laps” button writes to laps.csvlaps.csv.

5. Multiple stopwatches

A TabTab per stopwatch using ttk.Notebookttk.Notebook. Each tab keeps its own state.

6. Color states

Green while running, red while stopped, blue at zero. Visual at a glance.

7. Sound on lap / finish

winsoundwinsound on Windows, playsoundplaysound cross-platform. See Basic Music Player.

8. Voice commands

pip install SpeechRecognitionpip install SpeechRecognition — say “start”, “stop”, “lap”, “reset”.

9. Race mode

Two stopwatches in one window, each with its own start/stop button. Show who is ahead.

10. Web stopwatch

Flask plus a tiny page that updates with JS. The server keeps state per session.

Class-Based Refactor

Globals are fine here, but if you build the multi-stopwatch variant you want state per instance:

oop.py
class Stopwatch:
    def __init__(self, parent):
        self.running = False
        self.elapsed = 0.0
        self.start_time = 0.0
        self.label = tk.Label(parent, text="00:00.0",
                              font=("Consolas", 60), bg="black", fg="white")
        self.label.pack()
    def start(self):
        if not self.running:
            self.running = True
            self.start_time = time.time()
            self._tick()
    def _tick(self):
        if not self.running: return
        now_elapsed = (time.time() - self.start_time) + self.elapsed
        self.label.config(text=format_time(now_elapsed))
        self.label.after(50, self._tick)
    def stop(self):
        if self.running:
            self.elapsed += time.time() - self.start_time
            self.running = False
    def reset(self):
        self.running = False
        self.elapsed = 0
        self.label.config(text="00:00.0")
oop.py
class Stopwatch:
    def __init__(self, parent):
        self.running = False
        self.elapsed = 0.0
        self.start_time = 0.0
        self.label = tk.Label(parent, text="00:00.0",
                              font=("Consolas", 60), bg="black", fg="white")
        self.label.pack()
    def start(self):
        if not self.running:
            self.running = True
            self.start_time = time.time()
            self._tick()
    def _tick(self):
        if not self.running: return
        now_elapsed = (time.time() - self.start_time) + self.elapsed
        self.label.config(text=format_time(now_elapsed))
        self.label.after(50, self._tick)
    def stop(self):
        if self.running:
            self.elapsed += time.time() - self.start_time
            self.running = False
    def reset(self):
        self.running = False
        self.elapsed = 0
        self.label.config(text="00:00.0")

Real-World Applications

  • Workout / interval training.
  • Cooking — multi-timer for parallel dishes.
  • Productivity — Pomodoro and time-tracking.
  • Sports — race day timing.
  • Lab experiments — recording multiple event durations.
  • Billing — track time on tasks.

Educational Value

  • Tkinter event loop & afterafter — the cornerstone of any GUI animation.
  • Time accuracy — counters drift, timestamps do not.
  • State managementrunningrunning, start_timestart_time, elapsed_at_pauseelapsed_at_pause.
  • Formattingdivmoddivmod is the right tool for time math.
  • Keyboard bindings — usability beyond mouse clicks.

Next Steps

  • Rewrite using time.time()time.time() for accuracy.
  • Add HH:MM:SS.tHH:MM:SS.t display.
  • Add lap times with a Listbox.
  • Add keyboard shortcuts.
  • Refactor into a StopwatchStopwatch class and host two of them in one window.
  • Pair with Basic Alarm Clock for a countdown / Pomodoro hybrid.

Conclusion

You built a real desktop stopwatch, then fixed the most common time-tracking bug (drift from counting ticks). The same lesson — measure moments, not steps — applies to nearly every time-sensitive program you will ever write. Full source on GitHub. More GUI projects on Python Central Hub.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did