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
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
- Create folder
simple-stopwatchsimple-stopwatch. - Inside, create
simplestopwatch.pysimplestopwatch.py.
Write the code
Simple Stopwatch
Source# 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
# 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
C:\Users\Your Name\simple-stopwatch> python simplestopwatch.py
# Window with big "0", Start / Stop / Reset buttonsC:\Users\Your Name\simple-stopwatch> python simplestopwatch.py
# Window with big "0", Start / Stop / Reset buttonsStep-by-Step Explanation
1. Imports and root window
import tkinter as tk
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("400x300")
root.config(bg="black")import tkinter as tk
root = tk.Tk()
root.title("Simple Stopwatch")
root.geometry("400x300")
root.config(bg="black")2. State and label
running = False
count = 0
label = tk.Label(root, text="0", font=("Helvetica", 80), bg="black", fg="white")
label.pack(pady=20)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
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")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
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()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:
- Tkinter’s
afterafteris 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. countcountmeasures “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():
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")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
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}"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: 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: 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:
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())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
| Problem | Cause | Fix |
|---|---|---|
| Drift after 5 minutes | Counted ticks, not elapsed time | Use time.time()time.time() reference |
Multiple tick()tick() chains spawn after rapid Start clicks | No guard on start()start() | if not running:if not running: before scheduling |
| Window freezes on heavy work | Did blocking work in callback | Keep callbacks fast; use threads for blocking ops |
| Reset while running leaves stale tick scheduled | Did not stop the chain | Set running = Falserunning = False before resetting |
| Tenths jitter visibly | Refreshed every 1000 ms | Refresh every 50–100 ms; format with fractional seconds |
| Numbers misaligned | Variable-width font | Use a monospace font (ConsolasConsolas, CourierCourier) |
Variations to Try
1. Countdown timer
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)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:
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")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 management —
runningrunning,start_timestart_time,elapsed_at_pauseelapsed_at_pause. - Formatting —
divmoddivmodis 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.tdisplay. - Add lap times with a Listbox.
- Add keyboard shortcuts.
- Refactor into a
StopwatchStopwatchclass 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 coffeeWas this page helpful?
Let us know how we did
