Python Projects for Placement Interviews: 10 Projects from Easy to Impressive (with Code)
The interviewer has seen four hundred to-do apps this season. What they have not seen is a candidate who can talk for ten minutes about one decision they made: why SQLite and not a CSV, why a dict and not a list, what broke and how they found it. That conversation is what a project is for in a placement interview. The project itself is the excuse to have it.
This page is the hub for that. Ten projects, ordered from a two-hour warm-up to something you can defend in a final round. For each one: what it proves to the interviewer, the three to five Python concepts it uses, the PyRun lesson that teaches each concept, and a working code stub you can run today. Then a section on how to present a project in the room, because most freshers lose the project round on presentation, not on code.
Every snippet on this page was run on Python 3.12 before publishing. Where a project needs a library that does not run in the browser (Flask, FastAPI), the stub says so.
Where this fits. Projects are one of three things a Python placement round tests. The other two are coding problems and language questions. For those, see the interview practice page (24 classic problems, checked in your browser), the Python cheat sheets (10 printable topics), and the 38 fresher interview questions. If you do not know where to start, the free level test takes five minutes and points you at the right lesson. For a week-by-week plan, see the Python roadmap for campus placements.
How to read the difficulty labels
- Easy (2 to 4 hours): you can finish it in one sitting with the basics. It proves you can ship something complete.
- Medium (5 to 10 hours): needs a library or a design decision. It proves you can read documentation and structure code.
- Impressive (10 to 20 hours): has a database, a web layer, tests or concurrency. It proves you can build something another person could use.
Build one from each band. Three finished projects with a README beat ten half-built repos, and the interviewer will only ask about one anyway.
Easy projects: prove you can finish something
1. Marks-to-grade report card (Easy)
Read student names and marks, compute totals, averages and grades, print a ranked report. Every fresher can do this; very few do it cleanly.
What it proves: you know the basics well enough to write functions with clear inputs and outputs, and you can sort by a key instead of writing your own loop.
Concepts: functions, dictionaries, sorted() with a key, f-string alignment, round().
Learn it on PyRun: Functions, Dictionaries, Strings, If / Else and Loops.
def grade(avg):
if avg >= 90: return "A"
if avg >= 75: return "B"
if avg >= 60: return "C"
return "D"
students = {
"Asha": [88, 92, 79],
"Rohit": [65, 70, 58],
"Meera": [95, 98, 91],
}
report = []
for name, marks in students.items():
avg = sum(marks) / len(marks)
report.append((name, sum(marks), round(avg, 1), grade(avg)))
report.sort(key=lambda row: row[1], reverse=True)
print(f"{'Rank':<5}{'Name':<8}{'Total':>6}{'Avg':>7} Grade")
for rank, (name, total, avg, g) in enumerate(report, start=1):
print(f"{rank:<5}{name:<8}{total:>6}{avg:>7} {g}")
Output:
Rank Name Total Avg Grade
1 Meera 284 94.7 A
2 Asha 259 86.3 B
3 Rohit 193 64.3 C
Follow-up you will get: "What if two students have the same total?" Answer: the sort is stable, so they keep input order; to break ties by name, sort by (-total, name).
2. Expense tracker with monthly summary (Easy)
Record expenses as rows of date, category, amount. Print spend per month and per category. Start with CSV in a file, because that is what the interviewer will ask you to defend.
What it proves: you can read and write structured data, parse dates, and aggregate without a library.
Concepts: the csv module, datetime.strptime, collections.defaultdict, file handling with with.
Learn it on PyRun: Dates and Time, Counter, defaultdict, namedtuple, Context Managers (with), Files and CSV in memory.
import csv
import io
from collections import defaultdict
from datetime import datetime
raw = """date,category,amount
03/08/2026,food,240
05/08/2026,travel,1200
21/08/2026,food,310
02/09/2026,books,899
14/09/2026,food,180
"""
by_month = defaultdict(float)
by_category = defaultdict(float)
for row in csv.DictReader(io.StringIO(raw)):
day = datetime.strptime(row["date"], "%d/%m/%Y")
amount = float(row["amount"])
by_month[day.strftime("%m/%Y")] += amount
by_category[row["category"]] += amount
for month, total in sorted(by_month.items()):
print(f"{month}: Rs {total:,.0f}")
print("top category:", max(by_category, key=by_category.get))
Output:
08/2026: Rs 1,750
09/2026: Rs 1,079
top category: travel
In the real project, replace io.StringIO(raw) with open("expenses.csv", newline=""). Keep the in-memory version as a test.
Follow-up you will get: "Why not just use Excel?" Good answer: this is the same computation, but it runs on 10 lakh rows, can be scheduled, and is testable.
3. To-do list CLI with JSON storage (Easy)
todo add "revise DBMS", todo done 2, todo list. The tasks survive between runs because they are saved to a JSON file.
What it proves: you can build a tool with a command-line interface, persist state, and handle a missing or corrupt file without crashing.
Concepts: argparse sub-commands, json.dump / json.load, pathlib.Path, try / except for FileNotFoundError.
Learn it on PyRun: JSON, Errors and try/except, Building CLI tools with argparse, Modules and Imports.
import argparse
import json
from pathlib import Path
STORE = Path("todo.json")
def load():
try:
return json.loads(STORE.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
def save(tasks):
STORE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
def main(argv=None):
parser = argparse.ArgumentParser(prog="todo")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("add").add_argument("text")
sub.add_parser("done").add_argument("n", type=int)
sub.add_parser("list")
args = parser.parse_args(argv)
tasks = load()
if args.cmd == "add":
tasks.append({"text": args.text, "done": False})
elif args.cmd == "done":
tasks[args.n - 1]["done"] = True
save(tasks)
for i, t in enumerate(tasks, 1):
print(f"{i}. [{'x' if t['done'] else ' '}] {t['text']}")
main(["add", "revise DBMS"])
main(["add", "solve two-sum"])
main(["done", "1"])
Each call prints the list after the command, so the three calls print:
1. [ ] revise DBMS
1. [ ] revise DBMS
2. [ ] solve two-sum
1. [x] revise DBMS
2. [ ] solve two-sum
In the real tool, call main() with no arguments so it reads sys.argv; the list form is how you test it.
Follow-up you will get: "What happens if done 7 is given with two tasks?" Right now, IndexError. Say so, then say how you would fix it (check the range, print a message, exit with code 1).
Medium projects: prove you can structure code
4. Server log analyser (Medium)
Feed it a web-server access log and get the top IPs, the status-code breakdown, and the slowest endpoints. This is real work that operations teams actually do in Python.
What it proves: regex, generators for large files, and the Counter idiom. It also gives you a natural story about performance: the file might be 5 GB, so you never read it into a list.
Concepts: re with named groups, generator functions, collections.Counter, most_common().
Learn it on PyRun: Regular Expressions, Generators, Counter, defaultdict, namedtuple, Regex data extraction.
import re
from collections import Counter
LINE = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<method>\w+) (?P<path>\S+) [^"]*" '
r'(?P<status>\d{3}) (?P<bytes>\d+)'
)
log = """\
103.21.58.7 - - [24/09/2026:10:01:02 +0530] "GET /login HTTP/1.1" 200 512
103.21.58.7 - - [24/09/2026:10:01:09 +0530] "POST /login HTTP/1.1" 302 0
49.36.12.90 - - [24/09/2026:10:02:44 +0530] "GET /results HTTP/1.1" 500 128
49.36.12.90 - - [24/09/2026:10:02:50 +0530] "GET /results HTTP/1.1" 200 2048
117.99.1.5 - - [24/09/2026:10:03:12 +0530] "GET /missing HTTP/1.1" 404 64
""".splitlines()
def parse(lines):
for line in lines:
m = LINE.match(line)
if m:
yield m.groupdict()
ips = Counter()
statuses = Counter()
for hit in parse(log):
ips[hit["ip"]] += 1
statuses[hit["status"][0] + "xx"] += 1
print("top ips:", ips.most_common(2))
print("status classes:", dict(sorted(statuses.items())))
Output:
top ips: [('103.21.58.7', 2), ('49.36.12.90', 2)]
status classes: {'2xx': 2, '3xx': 1, '4xx': 1, '5xx': 1}
For the real file, parse(open("access.log")) works unchanged, because a file object yields lines one at a time.
Follow-up you will get: "Why a generator and not a list?" Because memory stays constant no matter how big the file is. Know the difference cold; the generators explainer covers it.
5. Bank account system with custom exceptions (Medium)
Accounts, deposits, withdrawals, a savings account that adds interest, and a proper error when someone overdraws. This is the standard OOP interview project, and it is standard because it exposes exactly the gaps interviewers want to find.
What it proves: you understand classes as more than a bag of functions: inheritance, super(), dunder methods and exceptions that carry meaning.
Concepts: classes and __init__, inheritance and super(), __repr__, custom exception classes, @property.
Learn it on PyRun: Classes and Objects, Inheritance and super(), Magic / Dunder Methods, Errors and try/except.
class InsufficientFunds(Exception):
def __init__(self, needed, available):
super().__init__(f"need Rs {needed}, have Rs {available}")
self.needed, self.available = needed, available
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise InsufficientFunds(amount, self._balance)
self._balance -= amount
def __repr__(self):
return f"{type(self).__name__}({self.owner!r}, balance={self._balance})"
class SavingsAccount(Account):
def __init__(self, owner, balance=0, rate=0.04):
super().__init__(owner, balance)
self.rate = rate
def add_interest(self):
self.deposit(round(self._balance * self.rate, 2))
acct = SavingsAccount("Priya", 5000)
acct.add_interest()
print(acct)
try:
acct.withdraw(10_000)
except InsufficientFunds as e:
print("blocked:", e)
Output:
SavingsAccount('Priya', balance=5200.0)
blocked: need Rs 10000, have Rs 5200.0
Follow-up you will get: "Why a custom exception instead of ValueError?" Because the caller can catch InsufficientFunds specifically and read .needed and .available off it, instead of parsing a message.
6. URL shortener with tests (Medium)
Turn a long URL into a 6-character code and back. The interesting parts are the encoding, the collision handling, and the fact that you wrote tests.
What it proves: you can reason about hashing and collisions, and you test your code. A tests/ folder is rare in fresher repos and interviewers notice it.
Concepts: hashlib, base-62 encoding, dict as an O(1) store, type hints, pytest.
Learn it on PyRun: Hashing: the O(1) lookup, Type Hints, Testing with pytest.
import hashlib
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def base62(n: int) -> str:
out = ""
while n:
n, r = divmod(n, 62)
out = ALPHABET[r] + out
return out or "0"
class Shortener:
def __init__(self) -> None:
self.store: dict[str, str] = {}
def shorten(self, url: str) -> str:
digest = hashlib.sha256(url.encode()).hexdigest()
code = base62(int(digest[:10], 16))[:6]
while code in self.store and self.store[code] != url:
code = base62(int(code, 36) + 1)[:6] # collision: nudge forward
self.store[code] = url
return code
def expand(self, code: str) -> str | None:
return self.store.get(code)
# tests (run with pytest, or as plain functions)
def test_round_trip():
s = Shortener()
code = s.shorten("https://pyrun.in/interview")
assert len(code) == 6
assert s.expand(code) == "https://pyrun.in/interview"
def test_same_url_same_code():
s = Shortener()
assert s.shorten("https://pyrun.in") == s.shorten("https://pyrun.in")
test_round_trip(); test_same_url_same_code()
print("2 tests passed; sample code:", Shortener().shorten("https://pyrun.in/cheatsheets"))
Save the tests in test_shortener.py and run pytest; the pytest tutorial for beginners shows the layout.
Follow-up you will get: "How many URLs before a 6-character code collides?" 62 to the power 6 is about 56.8 billion codes; by the birthday bound you expect a collision after roughly the square root, around 240,000. That is why the collision loop exists.
7. Sales data analysis with pandas (Medium)
Load a CSV of orders, clean it, and answer three business questions: revenue by month, top products, and the average order value per city. This is the project for anyone aiming at data or analyst roles.
What it proves: you can go from a messy file to a correct answer, and you can explain groupby without hand-waving.
Concepts: pd.read_csv, to_datetime, groupby().agg(), sorting, handling missing values.
Learn it on PyRun: Pandas: tabular data, groupby and aggregation, Cleaning messy data, pandas cheat sheet. pandas runs in the PyRun browser editor, so you can try this without installing anything.
import io
import pandas as pd
csv = """order_id,date,city,product,qty,price
1,02/08/2026,Pune,earbuds,2,1499
2,05/08/2026,Delhi,charger,1,699
3,19/08/2026,Pune,earbuds,1,1499
4,03/09/2026,Chennai,cable,3,199
5,11/09/2026,Delhi,earbuds,1,
6,15/09/2026,Pune,charger,2,699
"""
df = pd.read_csv(io.StringIO(csv))
df["date"] = pd.to_datetime(df["date"], format="%d/%m/%Y")
df["price"] = df["price"].fillna(df.groupby("product")["price"].transform("median"))
df["revenue"] = df["qty"] * df["price"]
print(df.groupby(df["date"].dt.strftime("%m/%Y"))["revenue"].sum())
print(df.groupby("product")["revenue"].sum().sort_values(ascending=False).head(2))
print(df.groupby("city")["revenue"].mean().round(0))
Output:
date
08/2026 5196.0
09/2026 3494.0
Name: revenue, dtype: float64
product
earbuds 5996.0
charger 2097.0
Name: revenue, dtype: float64
city
Chennai 597.0
Delhi 1099.0
Pune 1965.0
Name: revenue, dtype: float64
Row 5 has a missing price; the fillna line fills it with the median price of that product. Be ready to say why the median and not zero.
Follow-up you will get: "What does transform do that agg does not?" agg collapses each group to one row; transform returns a value for every original row, which is why it can be assigned back into the column.
Impressive projects: prove someone else could use it
8. Notes web app with Flask and SQLite (Impressive)
A small web app: create, list and delete notes, stored in SQLite, rendered with templates. Deploy it and put the URL on your resume.
What it proves: you understand HTTP request and response, routing, and how a web app talks to a database. This is the minimum for any backend-leaning role.
Concepts: Flask routes, request.form, sqlite3 with parameterised queries, redirects, a test_client.
Learn it on PyRun: Your first Flask web server, SQLite with Python's sqlite3, HTTP fundamentals with requests. Flask does not run inside the browser editor; install it locally with pip install flask (see the install guide).
import sqlite3
from flask import Flask, request, redirect
app = Flask(__name__)
DB = ":memory:" # use "notes.db" for a real file
conn = sqlite3.connect(DB, check_same_thread=False)
conn.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)")
@app.get("/")
def index():
rows = conn.execute("SELECT id, body FROM notes ORDER BY id DESC").fetchall()
items = "".join(f"<li>{body}</li>" for _, body in rows)
return f"<h1>Notes</h1><ul>{items}</ul>"
@app.post("/add")
def add():
body = request.form["body"].strip()
if body:
conn.execute("INSERT INTO notes (body) VALUES (?)", (body,)) # parameterised
conn.commit()
return redirect("/")
# exercise it without a browser
client = app.test_client()
client.post("/add", data={"body": "revise SQL joins"})
client.post("/add", data={"body": "mock interview on Saturday"})
html = client.get("/").get_data(as_text=True)
print(html)
Output:
<h1>Notes</h1><ul><li>mock interview on Saturday</li><li>revise SQL joins</li></ul>
Note that the string in <li> is not escaped here; in the real app render it through a Jinja template, which escapes by default. Saying that unprompted is worth more than the app.
Follow-up you will get: "Why the ? placeholder instead of an f-string in the SQL?" SQL injection. Have the one-line explanation ready.
9. To-do REST API with FastAPI and token auth (Impressive)
Not a to-do app; a to-do API. Users register, get a token, and can only see their own tasks. The interviewer can open /docs and click around, which no other fresher project lets them do.
What it proves: REST design, validation, authentication, and the vocabulary (status codes, dependencies, schemas) that backend interviews run on.
Concepts: FastAPI path operations, pydantic models, Depends, HTTP status codes, secrets for tokens.
Learn it on PyRun: REST APIs with FastAPI, Auth: password hashing and JWTs, Deploying a Python web app. FastAPI runs locally, not in the browser editor: pip install fastapi httpx.
import secrets
from fastapi import FastAPI, Depends, Header, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
tokens: dict[str, str] = {} # token -> username
todos: dict[str, list[dict]] = {} # username -> tasks
class Todo(BaseModel):
title: str
done: bool = False
def current_user(authorization: str = Header()) -> str:
token = authorization.removeprefix("Bearer ")
if token not in tokens:
raise HTTPException(status_code=401, detail="invalid token")
return tokens[token]
@app.post("/login")
def login(username: str):
token = secrets.token_urlsafe(16)
tokens[token] = username
return {"token": token}
@app.post("/todos", status_code=201)
def create(todo: Todo, user: str = Depends(current_user)):
todos.setdefault(user, []).append(todo.model_dump())
return todo
@app.get("/todos")
def mine(user: str = Depends(current_user)):
return todos.get(user, [])
client = TestClient(app)
tok = client.post("/login", params={"username": "asha"}).json()["token"]
h = {"Authorization": f"Bearer {tok}"}
print(client.post("/todos", json={"title": "apply to 3 companies"}, headers=h).status_code)
print(client.get("/todos", headers=h).json())
print(client.get("/todos", headers={"Authorization": "Bearer wrong"}).status_code)
Output:
201
[{'title': 'apply to 3 companies', 'done': False}]
401
This stub keeps everything in memory and uses a random token as the session. The real project replaces the dicts with SQLite and the token with a signed JWT, and hashes passwords with bcrypt; the auth lesson walks through both.
Follow-up you will get: "Why 201 for create and not 200?" 201 means a resource was created. Knowing two or three status codes beyond 200 and 404 signals you have actually built an API.
10. Concurrent link checker with asyncio (Impressive)
Give it 500 URLs and it reports which ones are dead, without taking 500 times as long. Rate-limited so it does not hammer one host.
What it proves: you understand the difference between I/O-bound and CPU-bound work, and can use concurrency correctly. Almost no fresher can explain this; the ones who can stand out immediately.
Concepts: async / await, asyncio.gather, asyncio.Semaphore, timeouts, the GIL and why threads would also have worked here.
Learn it on PyRun: async / await, Concurrency: threads vs asyncio, Logging in production. Read the async explainer first if await is new.
import asyncio
import random
import time
async def check(url, sem):
async with sem: # at most 5 in flight
await asyncio.sleep(0.3) # stands in for the network call
status = 404 if url.endswith("/old") else 200
return url, status
async def main(urls):
sem = asyncio.Semaphore(5)
results = await asyncio.gather(*(check(u, sem) for u in urls))
dead = [u for u, s in results if s != 200]
return results, dead
urls = [f"https://example.com/page{i}" for i in range(18)] + ["https://example.com/old"]
start = time.perf_counter()
results, dead = asyncio.run(main(urls))
elapsed = time.perf_counter() - start
print(f"checked {len(results)} urls in {elapsed:.1f}s (sequential would be ~{0.3 * len(urls):.1f}s)")
print("dead:", dead)
Output (timing measured on Python 3.12):
checked 19 urls in 1.2s (sequential would be ~5.7s)
dead: ['https://example.com/old']
19 URLs at 0.3 s each with 5 in flight is 4 batches, about 1.2 s. Swap asyncio.sleep for a real request with httpx.AsyncClient when you run it locally.
Follow-up you will get: "Would threads have worked?" Yes, because the work is I/O-bound and the GIL is released while waiting. Explain when you would pick multiprocessing instead (CPU-bound work). That answer alone covers three separate interview questions.
How to present a project in a placement interview
You will get about ten minutes. Here is how to use them.
1. Open with the problem, not the stack. "I built a tool that finds the top error-producing IPs in a server log" beats "I used Python, regex and Counter". The stack comes second.
2. Give one number. Rows processed, requests per second, hours it saves, tests written. "It parses a 2 GB log in under a minute on my laptop" is memorable. Make sure the number is true and you measured it; be ready to say how.
3. Tell one decision and its trade-off. Why SQLite, not Postgres (single user, zero setup, and I know the migration path). Why a generator, not a list (memory stays flat). Why asyncio, not threads (I/O-bound, simpler cancellation). One decision, explained well, is the whole point of the round.
4. Tell one bug. What broke, how you found it, what you changed. "The month totals were wrong because I compared date strings, not dates" is a better story than a bug-free project, because it shows you debug.
5. Say what you would do next. Tests, deployment, a second data source. It shows you know the project is not finished and you know what finished looks like.
6. Have it runnable on your phone. Interview Wi-Fi fails. A deployed URL, a short screen recording, or a GitHub README with a screenshot means you can still show it.
What the README must contain: one sentence of what it does, a screenshot or sample output, how to run it (three commands maximum), and what you learned. Interviewers open the README before they open the code.
What to remove from the repo before the interview: committed .env files and API keys, __pycache__, a 200 MB dataset. Add a .gitignore. The Git for Python devs lesson covers the basics.
Questions you will be asked regardless of the project: "What is the time complexity of the main operation?", "How would you test this?", "What happens when the input is empty or malformed?", "How would it behave with 100 times the data?" Prepare a one-line answer to each for whichever project you present.
Which three to build
If you have four weeks: #3 (to-do CLI), #5 (bank accounts) and either #8 (Flask notes) for backend roles or #7 (pandas sales) for data roles. If you have a weekend: #1 and #4, done properly, with a README. The final-year projects post has larger ideas with a four-week build plan if you need something for a college submission as well.
PyRun's own projects track has three guided builds (a Hacker News scraper, a spending analyser, a Telegram summariser bot) with a starter file and a requirements checklist each. You draft them in a browser workspace, but the finished versions are meant to be run on your own machine (they fetch from the network and talk to Telegram).
Practise the rest of the round. Coding problems: 24 interview problems with checkers. Language questions: 38 fresher interview questions. Syntax you keep forgetting: 10 printable cheat sheets. Not sure where you stand: the level test (15 questions, no account needed).
A note on what PyRun teaches. The first five lessons are free, no account or card needed; the rest of the curriculum, including the DSA, web, data and testing lessons linked above, is part of the paid plan, with a 7-day trial. The practice editor, cheat sheets, interview problems and level test are free.