Reading syntax tutorials teaches you what a for loop does. Building a small project teaches you when to reach for one. This guide gives you 15 beginner-friendly Python mini projects, each with a clear goal, the concepts it trains, and starter code you can run immediately. The projects progress from basic input and output to file handling, APIs, and simple object-oriented design.
Quick Takeaways
- 15 projects are ordered by difficulty, from 10-line scripts to small multi-function programs.
- Each project trains a specific skill: string handling, loops, dictionaries, file I/O, classes, HTTP requests, or modules.
- All starter code uses the Python 3.10+ standard library, except two that use
requests. - Extend every project with the “Level Up” idea to turn a tutorial exercise into a portfolio piece.
| # | Project | Core Concept | Difficulty |
|---|---|---|---|
| 1 | Number Guessing Game | Loops, random |
Easy |
| 2 | Tip Calculator | Input, float math | Easy |
| 3 | Password Generator | secrets, strings |
Easy |
| 4 | Rock Paper Scissors | Conditionals, dicts | Easy |
| 5 | Unit Converter | Functions, dicts | Easy |
| 6 | To-Do List (CLI) | Lists, loops | Easy |
| 7 | Word Counter | collections.Counter |
Medium |
| 8 | Dice Roller Simulator | random, validation |
Medium |
| 9 | Contact Book | Dicts, JSON file I/O | Medium |
| 10 | Quiz App | Data structures | Medium |
| 11 | Expense Tracker | CSV, aggregation | Medium |
| 12 | Hangman | State management | Medium |
| 13 | Weather CLI | requests, JSON |
Medium |
| 14 | Bank Account Class | OOP | Advanced beginner |
| 15 | File Organizer | pathlib, shutil |
Advanced beginner |
How to Use These Python Beginner Projects
Follow this loop for each project:
- Read the goal and write the steps in plain English first.
- Type the starter code yourself. Do not copy and paste.
- Break it on purpose. Enter letters where numbers are expected.
- Extend it using the Level Up idea.
Set up a virtual environment once so projects stay isolated:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
Run any script with python filename.py.
Beginner Projects (1-6)
1. Number Guessing Game
Concepts: while loops, random.randint(), int() conversion, exception handling.
The computer picks a number. The player guesses until correct.
import random
def play(low: int = 1, high: int = 100) -> None:
secret = random.randint(low, high)
attempts = 0
while True:
raw = input(f"Guess a number between {low} and {high}: ")
try:
guess = int(raw)
except ValueError:
print("Enter a whole number.")
continue
attempts += 1
if guess < secret:
print("Too low.")
elif guess > secret:
print("Too high.")
else:
print(f"Correct! You needed {attempts} attempts.")
break
if __name__ == "__main__":
play()
The try/except block catches ValueError so typing abc does not crash the program. The if __name__ == "__main__" guard lets you import play() elsewhere without running the game.
Level Up: Limit attempts to 7 and add a difficulty menu.
2. Tip Calculator
Concepts: float(), formatted strings, basic arithmetic.
def calculate_tip(bill: float, tip_percent: float, people: int = 1) -> dict:
tip = bill * tip_percent / 100
total = bill + tip
return {"tip": round(tip, 2), "total": round(total, 2),
"per_person": round(total / people, 2)}
bill = float(input("Bill amount: "))
percent = float(input("Tip percent: "))
people = int(input("Split between how many people? "))
result = calculate_tip(bill, percent, people)
print(f"Tip: ${result['tip']:.2f}")
print(f"Total: ${result['total']:.2f}")
print(f"Each pays: ${result['per_person']:.2f}")
Entering 50, 20, 2 prints a tip of $10.00, total of $60.00, and $30.00 each. The :.2f format spec forces two decimal places.
Level Up: Reject negative values and a zero head count.
3. Password Generator
Concepts: the secrets module, string constants, str.join().
Use secrets, not random, for anything security-related. random is predictable. secrets draws from the operating system’s cryptographic source.
import secrets
import string
def generate_password(length: int = 16, symbols: bool = True) -> str:
alphabet = string.ascii_letters + string.digits
if symbols:
alphabet += string.punctuation
return "".join(secrets.choice(alphabet) for _ in range(length))
print(generate_password(20))
The generator expression builds one character per iteration, then "".join() concatenates them. Output differs on every run.
Level Up: Guarantee at least one digit, one uppercase letter, and one symbol.
4. Rock Paper Scissors
Concepts: dictionaries as lookup tables, conditionals.
import random
BEATS = {"rock": "scissors", "paper": "rock", "scissors": "paper"}
def judge(player: str, computer: str) -> str:
if player == computer:
return "Draw"
return "You win" if BEATS[player] == computer else "You lose"
player = input("rock, paper, or scissors? ").strip().lower()
if player not in BEATS:
print("Invalid choice.")
else:
computer = random.choice(list(BEATS))
print(f"Computer chose {computer}. {judge(player, computer)}.")
The BEATS dictionary replaces a chain of nine if statements. Each key maps to the choice it defeats.
Level Up: Add a best-of-five scoreboard, then add “lizard” and “spock”.
5. Unit Converter
Concepts: functions, dictionary-based dispatch.
CONVERSIONS = {
("km", "miles"): lambda x: x * 0.621371,
("miles", "km"): lambda x: x / 0.621371,
("kg", "lbs"): lambda x: x * 2.20462,
("lbs", "kg"): lambda x: x / 2.20462,
("c", "f"): lambda x: x * 9 / 5 + 32,
("f", "c"): lambda x: (x - 32) * 5 / 9,
}
def convert(value: float, src: str, dst: str) -> float:
try:
return CONVERSIONS[(src, dst)](value)
except KeyError:
raise ValueError(f"Unsupported conversion: {src} -> {dst}")
print(round(convert(100, "c", "f"), 2)) # 212.0
print(round(convert(10, "km", "miles"), 2)) # 6.21
A tuple key (src, dst) selects the right function. Adding a new conversion means adding one line, not another elif.
Level Up: Build a menu loop and support length, weight, and volume categories.
6. Command-Line To-Do List
Concepts: lists, enumerate(), menu loops.
def show(tasks: list[str]) -> None:
if not tasks:
print("No tasks.")
for i, task in enumerate(tasks, start=1):
print(f"{i}. {task}")
def main() -> None:
tasks: list[str] = []
while True:
cmd = input("[a]dd, [d]elete, [l]ist, [q]uit: ").strip().lower()
if cmd == "a":
tasks.append(input("Task: ").strip())
elif cmd == "d":
show(tasks)
idx = int(input("Number to delete: ")) - 1
if 0 <= idx < len(tasks):
tasks.pop(idx)
elif cmd == "l":
show(tasks)
elif cmd == "q":
break
main()
The bounds check 0 <= idx < len(tasks) prevents an IndexError. Tasks vanish when the program exits.
Level Up: Save tasks to a file (see project 9) and add a “done” flag.
Intermediate Beginner Projects (7-13)
7. Word Counter
Concepts: str.split(), collections.Counter, re.
import re
from collections import Counter
def top_words(text: str, n: int = 5) -> list[tuple[str, int]]:
words = re.findall(r"[a-z']+", text.lower())
return Counter(words).most_common(n)
sample = "the cat and the hat and the bat"
for word, count in top_words(sample, 3):
print(f"{word}: {count}")
Output:
the: 3
and: 2
cat: 1
Counter.most_common() runs in roughly O(n log k) time. The regex strips punctuation so "hat," and "hat" count as one word.
Level Up: Read a .txt file and ignore stop words like “the” and “and”.
8. Dice Roller Simulator
Concepts: string parsing, input validation, random.
Parse notation like 2d6 (two six-sided dice).
import random
import re
def roll(notation: str) -> tuple[list[int], int]:
match = re.fullmatch(r"(\d+)d(\d+)", notation.strip().lower())
if not match:
raise ValueError("Use the format NdM, e.g. 2d6")
count, sides = map(int, match.groups())
rolls = [random.randint(1, sides) for _ in range(count)]
return rolls, sum(rolls)
rolls, total = roll("3d6")
print(f"Rolls: {rolls}, total: {total}")
re.fullmatch rejects strings with trailing junk, which re.match would accept.
Level Up: Support modifiers like 2d6+3.
9. Contact Book (JSON Storage)
Concepts: dictionaries, JSON serialization, file I/O, pathlib.
import json
from pathlib import Path
DB = Path("contacts.json")
def load() -> dict[str, str]:
return json.loads(DB.read_text()) if DB.exists() else {}
def save(contacts: dict[str, str]) -> None:
DB.write_text(json.dumps(contacts, indent=2))
contacts = load()
contacts["Ada Lovelace"] = "555-0100"
save(contacts)
print(load())
The script writes a human-readable contacts.json and reloads it on the next run. Data now persists between sessions.
Level Up: Add search, edit, and delete commands.
10. Quiz App
Concepts: lists of dictionaries, scoring logic.
QUESTIONS = [
{"q": "Which keyword defines a function?", "a": "def"},
{"q": "What does len([1, 2, 3]) return?", "a": "3"},
{"q": "Is a tuple mutable? (yes/no)", "a": "no"},
]
def run_quiz(questions: list[dict]) -> int:
score = 0
for item in questions:
answer = input(item["q"] + " ").strip().lower()
if answer == item["a"]:
score += 1
print("Correct.")
else:
print(f"Wrong. Answer: {item['a']}")
return score
score = run_quiz(QUESTIONS)
print(f"Score: {score}/{len(QUESTIONS)}")
Separating data (QUESTIONS) from logic (run_quiz) lets you swap question sets without touching the engine.
Level Up: Load questions from a JSON file and shuffle them with random.shuffle().
11. Expense Tracker
Concepts: CSV handling, datetime, aggregation with defaultdict.
import csv
from collections import defaultdict
from datetime import date
from pathlib import Path
FILE = Path("expenses.csv")
def add_expense(category: str, amount: float) -> None:
new_file = not FILE.exists()
with FILE.open("a", newline="") as f:
writer = csv.writer(f)
if new_file:
writer.writerow(["date", "category", "amount"])
writer.writerow([date.today().isoformat(), category, amount])
def totals_by_category() -> dict[str, float]:
totals: dict[str, float] = defaultdict(float)
with FILE.open() as f:
for row in csv.DictReader(f):
totals[row["category"]] += float(row["amount"])
return dict(totals)
add_expense("food", 12.50)
add_expense("transport", 3.20)
add_expense("food", 8.00)
print(totals_by_category())
Output: {'food': 20.5, 'transport': 3.2}. Opening the file in append mode ("a") preserves earlier rows. defaultdict(float) removes the need to check whether a key exists.
Level Up: Filter totals by month and chart them with matplotlib.
12. Hangman
Concepts: sets, state tracking, string building.
import random
WORDS = ["python", "variable", "function", "iterator", "decorator"]
def hangman(max_wrong: int = 6) -> None:
word = random.choice(WORDS)
guessed: set[str] = set()
wrong = 0
while wrong < max_wrong:
display = " ".join(c if c in guessed else "_" for c in word)
print(display)
if "_" not in display:
print("You won!")
return
letter = input("Letter: ").strip().lower()
if len(letter) != 1 or not letter.isalpha() or letter in guessed:
print("Invalid or repeated.")
continue
guessed.add(letter)
if letter not in word:
wrong += 1
print(f"Wrong. {max_wrong - wrong} left.")
print(f"You lost. The word was '{word}'.")
hangman()
A set gives O(1) membership checks and rejects duplicate guesses automatically.
Level Up: Add ASCII art for each wrong guess.
13. Weather CLI (API Request)
Concepts: HTTP requests, JSON parsing, error handling. Requires pip install requests.
This example uses the free Open-Meteo API, which needs no key.
import requests
def current_temp(lat: float, lon: float) -> float:
url = "https://api.open-meteo.com/v1/forecast"
params = {"latitude": lat, "longitude": lon, "current_weather": True}
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
return response.json()["current_weather"]["temperature"]
try:
print(f"Temperature: {current_temp(40.71, -74.01)} °C")
except requests.RequestException as exc:
print(f"Request failed: {exc}")
Always set timeout. Without it, requests can hang forever. raise_for_status() converts HTTP 4xx and 5xx responses into exceptions.
Level Up: Accept a city name and geocode it with Open-Meteo’s geocoding endpoint.
Advanced Beginner Projects (14-15)
14. Bank Account Class
Concepts: classes, encapsulation, custom exceptions, __repr__.
class InsufficientFunds(Exception):
pass
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner
self._balance = balance
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive.")
self._balance += amount
def withdraw(self, amount: float) -> None:
if amount > self._balance:
raise InsufficientFunds(f"Balance {self._balance}, requested {amount}")
self._balance -= amount
def __repr__(self) -> str:
return f"BankAccount({self.owner!r}, balance={self._balance:.2f})"
acct = BankAccount("Grace", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct) # BankAccount('Grace', balance=120.00)
The leading underscore on _balance signals “internal.” The @property exposes it read-only, so outside code cannot set the balance directly.
Level Up: Add a transaction history list and a SavingsAccount subclass with interest.
15. File Organizer
Concepts: pathlib, shutil, dictionaries, safe file operations.
import shutil
from pathlib import Path
CATEGORIES = {
"Images": {".jpg", ".jpeg", ".png", ".gif"},
"Documents": {".pdf", ".docx", ".txt"},
"Archives": {".zip", ".tar", ".gz"},
}
def organize(folder: str, dry_run: bool = True) -> None:
root = Path(folder)
for item in root.iterdir():
if not item.is_file():
continue
for category, extensions in CATEGORIES.items():
if item.suffix.lower() in extensions:
target = root / category
print(f"{item.name} -> {category}/")
if not dry_run:
target.mkdir(exist_ok=True)
shutil.move(str(item), target / item.name)
break
organize("./Downloads", dry_run=True)
The dry_run flag previews every move before touching a file. Always build this safeguard into scripts that modify the filesystem.
Level Up: Handle filename collisions and log every move to a file.
Skills Matrix: What Each Project Teaches
| Skill | Projects |
|---|---|
| Loops and conditionals | 1, 4, 6, 12 |
| Dictionaries | 4, 5, 9, 10 |
| Functions and type hints | 2, 5, 8 |
| File I/O (JSON, CSV) | 9, 11 |
| Standard library modules | 3, 7, 11, 15 |
| External APIs | 13 |
| Object-oriented design | 14 |
| Error handling | 1, 8, 13, 14 |
Bad Code vs. Good Code: Refactoring Beginner Scripts
Most first projects share the same weaknesses. Here is a before-and-after on the tip calculator.
Anti-pattern
# Bad: no validation, magic numbers, mixed concerns, crashes on bad input
b = float(input("bill"))
t = float(input("tip"))
print(b + b * t / 100)
Problems: cryptic names, no error handling, no reusable function, no formatting.
Refactored
def parse_positive(prompt: str) -> float:
"""Prompt until the user enters a positive number."""
while True:
try:
value = float(input(prompt))
if value > 0:
return value
print("Value must be greater than zero.")
except ValueError:
print("Enter a valid number.")
def total_with_tip(bill: float, tip_percent: float) -> float:
return round(bill * (1 + tip_percent / 100), 2)
if __name__ == "__main__":
bill = parse_positive("Bill amount: ")
tip = parse_positive("Tip percent: ")
print(f"Total: ${total_with_tip(bill, tip):.2f}")
| Aspect | Bad Code | Good Code |
|---|---|---|
| Naming | b, t |
bill, tip_percent |
| Input safety | Crashes on text | Retries until valid |
| Reusability | None | Pure function, testable |
| Importable | No | Yes, via __main__ guard |
Common Mistakes to Avoid
- Mutable default arguments.
def add(item, bucket=[])shares one list across all calls. Usebucket=Noneand create the list inside. - Using
randomfor passwords. Usesecrets. - Opening files without a context manager. Always use
with open(...). - Catching bare
except:. It hides bugs. Catch the specific exception. - Skipping the
__main__guard. Your script then runs on import.
What to Do After These Projects
- Put every project in a Git repository with a short README.
- Add pytest tests for the pure functions (
judge(),convert(),top_words()). - Combine projects: wire the expense tracker to a Flask or FastAPI endpoint.
- Learn virtual environments and requirements.txt to share your work.
- Read your old code a week later and refactor it.
FAQ
What is the best first Python project for a complete beginner?
The number guessing game. It uses only variables, a loop, a conditional, and one import. You can finish it in under 30 minutes and it teaches input validation immediately.
How many Python projects do I need before applying for jobs?
Three to five polished projects beat fifteen unfinished ones. Pick projects that show different skills, such as an API client, a file-based data tool, and a class-based design. Include tests and a README for each.
Can I build these projects without installing any libraries?
Yes. Fourteen of the fifteen use only the standard library. Only the Weather CLI requires the third-party requests package, installed with pip install requests.
How long does each Python mini project take?
Projects 1-6 take 20-60 minutes. Projects 7-13 take one to two hours. Projects 14-15 take two to three hours including the Level Up extensions.




