CRUD Examples — Python and SQLite
A2 Level — Unit 5: A Programmed Solution to a Problem (NEA)
Common create, read, update and delete tasks for your NEA, written as methods on the Database class from the App Skeleton. Each example is labelled with the kind of system it comes from — [Rugby], [Theatre school], [Record shop], [Progress tracker], [Library] — so change the table and column names to match your own design.
Each example has pseudo-code first, showing the steps without any Python or SQLite detail, and then the Python that does it. The pseudo-code is a good model for the algorithm designs in your NEA write-up.
Every method on this page goes inside the Database class in database.py. Your forms call them through the shared object, e.g. self.db.add_team("Cardiff", 1). forms.py still never touches sqlite3.
1. How every method works
Each method follows the same steps. Here they are using update_user_email from the skeleton:
Pseudo-code:
FUNCTION updateUserEmail(userID, newEmail)
OPEN connection TO database
EXECUTE "UPDATE users SET email = newEmail WHERE id = userID"
COMMIT changes
SET success TO (rows changed > 0)
CLOSE connection
RETURN success
END FUNCTION
Python:
def update_user_email(self, user_id, new_email):
connection = self._connect() # 1. open the database
cursor = connection.cursor() # 2. get a cursor to run SQL
cursor.execute( # 3. run the SQL, with the values
"UPDATE users SET email = :new_email " # passed separately in a dict
"WHERE id = :user_id",
{"new_email": new_email, "user_id": user_id},
)
connection.commit() # 4. save the change
success = cursor.rowcount > 0 # 5. check what happened
connection.close() # 6. close the database
return success # 7. tell the form
A SELECT doesn’t change anything, so it skips the commit() and uses fetchone() or fetchall() at step 5 instead.
Putting values into the SQL
| Rule | Example |
|---|---|
| Every value goes in as a named placeholder, never joined into the string. | "WHERE name = :name" with {"name": name} |
| Placeholders have no quotes around them, even for text. | :name, not ':name' |
Numbers don’t need str(). SQLite gets the real number. |
{"user_id": 5} |
Dates are stored as text in YYYY-MM-DD format. |
'2026-09-22' |
date('now') gives today’s date inside the SQL. |
"SET DateReturned = date('now')" |
| A fixed value that never comes from the user can be typed straight into the SQL. | "VALUES (..., 'Booked')" |
| Each piece of the string needs a space at the end, or words run together. | "FROM users " "WHERE id = :id" |
Why placeholders and not +? If you write "WHERE name = '" + name + "'", a name like O’Brien breaks the SQL (the apostrophe ends the string early), and a user who types ' OR '1'='1 into a login box can change what the query does. That’s SQL injection. With :name the value is never part of the SQL text, so neither can happen, and you don’t have to remember quotes or str().
What to return to the form
| After | Use | Gives you |
|---|---|---|
INSERT |
cursor.lastrowid |
the ID of the new row |
UPDATE or DELETE |
cursor.rowcount |
how many rows changed (0 means nothing matched) |
SELECT, one row |
cursor.fetchone() |
one row, or None if nothing matched |
SELECT, many rows |
cursor.fetchall() |
a list of rows (empty if nothing matched) |
Because the skeleton’s _connect() sets row_factory = sqlite3.Row, every row can be read by column name (row["Title"]) or by position (row[0]).
2. Create
[Rugby] Add a row and return its new ID
Pseudo-code:
FUNCTION addTeam(name, leagueID)
OPEN connection TO database
EXECUTE "INSERT INTO team (name, league_id) VALUES (name, leagueID)"
COMMIT changes
SET newID TO ID of the new row
CLOSE connection
RETURN newID
END FUNCTION
Python:
def add_team(self, name, league_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"INSERT INTO team (name, league_id) "
"VALUES (:name, :league_id)",
{"name": name, "league_id": league_id},
)
connection.commit()
new_id = cursor.lastrowid
connection.close()
return new_id
Every column in the INSERT gets its own placeholder, and the dictionary has a key with the same name.
[Theatre school] Stop duplicates with try and except
The Booking table has a UNIQUE rule on (StudentID, ClassID), so booking the same student onto the same class twice raises an IntegrityError. This uses the same try/except/finally shape as create_user:
Pseudo-code:
FUNCTION bookClass(studentID, classID)
OPEN connection TO database
TRY
EXECUTE "INSERT INTO Booking (StudentID, ClassID, DateBooked, BookingStatus)
VALUES (studentID, classID, today, 'Booked')"
COMMIT changes
RETURN ID of the new row
CATCH duplicate error // already booked on this class
RETURN NULL
FINALLY
CLOSE connection // runs whichever way it ends
END TRY
END FUNCTION
Python:
def book_class(self, student_id, class_id):
connection = self._connect()
cursor = connection.cursor()
try:
cursor.execute(
"INSERT INTO Booking (StudentID, ClassID, DateBooked, BookingStatus) "
"VALUES (:student_id, :class_id, date('now'), 'Booked')",
{"student_id": student_id, "class_id": class_id},
)
connection.commit()
return cursor.lastrowid
except sqlite3.IntegrityError:
# Student already booked on this class
return None
finally:
connection.close()
The form can check for None and tell the user the student is already booked. The finally block runs whichever way the method ends, so the connection is always closed.
[Rugby] Two inserts saved together: recording a result
A result is two rows in the matchteam link table, one home and one away. Both are run before a single commit(). If the second insert fails, the commit is never reached and rollback() throws the first row away too, so the database never holds half a result.
Pseudo-code:
FUNCTION recordResult(matchID, homeID, homePoints, homeTries,
awayID, awayPoints, awayTries)
OPEN connection TO database
TRY
EXECUTE "INSERT INTO matchteam VALUES (matchID, homeID, 'H', homePoints, homeTries)"
EXECUTE "INSERT INTO matchteam VALUES (matchID, awayID, 'A', awayPoints, awayTries)"
COMMIT changes // both rows saved together
RETURN TRUE
CATCH duplicate error // result already entered
ROLLBACK changes // undo the home row too
RETURN FALSE
FINALLY
CLOSE connection
END TRY
END FUNCTION
Python:
def record_result(self, match_id, home_id, home_points, home_tries,
away_id, away_points, away_tries):
connection = self._connect()
cursor = connection.cursor()
sql = (
"INSERT INTO matchteam (match_id, team_id, home_away, points, tries) "
"VALUES (:match_id, :team_id, :home_away, :points, :tries)"
)
try:
cursor.execute(sql, {"match_id": match_id, "team_id": home_id,
"home_away": "H", "points": home_points,
"tries": home_tries})
cursor.execute(sql, {"match_id": match_id, "team_id": away_id,
"home_away": "A", "points": away_points,
"tries": away_tries})
connection.commit() # both rows are saved together
return True
except sqlite3.IntegrityError:
# Result already entered for this match
connection.rollback() # undo the home row if it went in
return False
finally:
connection.close()
Because the values are kept separate from the SQL, the same sql string is used for both rows, with a different dictionary each time.
[Record shop] Using the new ID in a second table: adding a vinyl
The Stock row is added first. Its lastrowid then becomes the StockID of the MusicItem row, which links the two.
Pseudo-code:
FUNCTION addVinyl(artistID, album, price, quantity, condition)
OPEN connection TO database
EXECUTE "INSERT INTO Stock (Category, Price, Quantity) VALUES ('Vinyl', price, quantity)"
SET stockID TO ID of the new Stock row
EXECUTE "INSERT INTO MusicItem (StockID, ArtistID, Album, Format, Condition)
VALUES (stockID, artistID, album, 'LP', condition)"
COMMIT changes
CLOSE connection
RETURN stockID
END FUNCTION
Python:
def add_vinyl(self, artist_id, album, price, quantity, condition):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"INSERT INTO Stock (Category, Price, Quantity) "
"VALUES ('Vinyl', :price, :quantity)",
{"price": price, "quantity": quantity},
)
stock_id = cursor.lastrowid # the ID of the Stock row just added
cursor.execute(
"INSERT INTO MusicItem (StockID, ArtistID, Album, Format, Condition) "
"VALUES (:stock_id, :artist_id, :album, 'LP', :condition)",
{"stock_id": stock_id, "artist_id": artist_id,
"album": album, "condition": condition},
)
connection.commit()
connection.close()
return stock_id
[Progress tracker] Adding several rows with a loop
INSERT OR IGNORE skips any pupil already in the class instead of raising an error, so running it twice is safe. The same idea works for adding several copies of a book in a library system.
Pseudo-code:
PROCEDURE addPupilsToClass(classID, pupilIDs)
OPEN connection TO database
FOR EACH pupilID IN pupilIDs
EXECUTE "INSERT OR IGNORE INTO ClassPupil (ClassID, PupilID)
VALUES (classID, pupilID)" // skips pupils already in the class
END FOR
COMMIT changes // one commit after the loop
CLOSE connection
END PROCEDURE
Python:
def add_pupils_to_class(self, class_id, pupil_ids):
connection = self._connect()
cursor = connection.cursor()
for pupil_id in pupil_ids:
cursor.execute(
"INSERT OR IGNORE INTO ClassPupil (ClassID, PupilID) "
"VALUES (:class_id, :pupil_id)",
{"class_id": class_id, "pupil_id": pupil_id},
)
connection.commit() # one commit after the loop
connection.close()
3. Read
[All projects] Using the rows in a form
Rows come back in the same order as the columns in the SELECT. For SELECT id, username, email, created_at, user["email"] and user[2] are the same value. Reading by name is clearer and won’t break if you change the column order later.
Pseudo-code:
SET user TO db.getUserByUsername("jstephenson")
IF user = NULL THEN
OUTPUT "No user with that username."
ELSE
SET emailBox TO user["email"]
END IF
FOR EACH user IN db.getAllUsers()
ADD (user["id"], user["username"], user["email"]) TO table
END FOR
Python:
user = self.db.get_user_by_username("jstephenson")
if user is None:
messagebox.showerror("Users", "No user with that username.", parent=self.win)
else:
self.email_entry.insert(0, user["email"])
for user in self.db.get_all_users(): # filling a ttk.Treeview
self.tree.insert("", "end", values=(user["id"], user["username"], user["email"]))
[All projects] Joins and column names
Reading rows by name still works when a query joins tables, but there are three things to watch for.
1. The name has no table in front. SELECT Pupil.FirstName ... is read as row["FirstName"], not row["Pupil.FirstName"].
2. Two columns with the same name clash. In a theatre school, Student and Parent might both have a FirstName column. This query gives a row with two keys called FirstName:
cursor.execute(
"SELECT Student.FirstName, Parent.FirstName "
"FROM Student "
"JOIN Parent ON Parent.ParentID = Student.ParentID"
)
row = cursor.fetchone()
row["FirstName"] # always the FIRST one: the student's name
There’s no error. You just get the wrong value for the second column without noticing. Rename each one with AS, and the names are different:
cursor.execute(
"SELECT Student.FirstName AS StudentName, "
"Parent.FirstName AS ParentName "
"FROM Student "
"JOIN Parent ON Parent.ParentID = Student.ParentID"
)
row = cursor.fetchone()
row["StudentName"] # the student's name
row["ParentName"] # the parent's name
The same thing happens with ID columns. If you SELECT Loan.CustomerID, Customer.CustomerID, only select one of them, since they hold the same value.
3. Worked-out columns need a name too. Without AS, the key is the whole expression, so COUNT(*) would have to be read as row["COUNT(*)"]. Write COUNT(*) AS played and read row["played"], as in the rugby totals example below.
Rule of thumb: in any SELECT with a JOIN, give a column an AS name if the same column name is in more than one of the tables, or if it’s worked out with COUNT, SUM, a sum or similar. To check what names a row has, print(row.keys()).
[Record shop] Searching with LIKE
The % signs mean “anything before or after”, so searching for thr finds “Thriller”. They’re added to the value, not the SQL. LIKE ignores upper and lower case for ordinary letters.
Pseudo-code:
FUNCTION searchMusic(text)
OPEN connection TO database
SET pattern TO "%" + text + "%" // % = anything before or after
EXECUTE "SELECT StockID, artist name, album, price, quantity
FROM Stock JOIN MusicItem JOIN Artist
WHERE artist name LIKE pattern OR album LIKE pattern
ORDER BY artist name, album"
SET results TO all matching rows
CLOSE connection
RETURN results
END FUNCTION
Python:
def search_music(self, text):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT Stock.StockID, Artist.Name, MusicItem.Album, "
"Stock.Price, Stock.Quantity "
"FROM Stock "
"JOIN MusicItem ON MusicItem.StockID = Stock.StockID "
"JOIN Artist ON Artist.ArtistID = MusicItem.ArtistID "
"WHERE Artist.Name LIKE :pattern "
"OR MusicItem.Album LIKE :pattern "
"ORDER BY Artist.Name, MusicItem.Album",
{"pattern": "%" + text + "%"},
)
results = cursor.fetchall()
connection.close()
return results
The same placeholder can appear more than once in the SQL. It only needs one entry in the dictionary.
[Progress tracker] Joining through a link table: a class list
ClassPupil only holds IDs, so it is joined to Pupil to get the names:
Pseudo-code:
FUNCTION getClassList(classID)
OPEN connection TO database
EXECUTE "SELECT PupilID, FirstName, Surname
FROM ClassPupil JOIN Pupil
WHERE ClassPupil.ClassID = classID
ORDER BY Surname, FirstName"
SET pupils TO all matching rows
CLOSE connection
RETURN pupils
END FUNCTION
Python:
def get_class_list(self, class_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT Pupil.PupilID, Pupil.FirstName, Pupil.Surname "
"FROM ClassPupil "
"JOIN Pupil ON Pupil.PupilID = ClassPupil.PupilID "
"WHERE ClassPupil.ClassID = :class_id "
"ORDER BY Pupil.Surname, Pupil.FirstName",
{"class_id": class_id},
)
pupils = cursor.fetchall()
connection.close()
return pupils
[Library] Comparing dates: overdue loans
This works because dates are stored as YYYY-MM-DD, which puts them in the right order as text. An empty (NULL) DateReturned means the book is still out. There are no values from the user, so there’s no dictionary.
Pseudo-code:
FUNCTION getOverdueLoans()
OPEN connection TO database
EXECUTE "SELECT LoanID, Title, FirstName, Surname, DueDate
FROM Loan JOIN Copy JOIN Book JOIN Customer
WHERE DateReturned IS NULL // still out
AND DueDate < today // and past its due date
ORDER BY DueDate"
SET loans TO all matching rows
CLOSE connection
RETURN loans
END FUNCTION
Python:
def get_overdue_loans(self):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT Loan.LoanID, Book.Title, Customer.FirstName, "
"Customer.Surname, Loan.DueDate "
"FROM Loan "
"JOIN Copy ON Copy.CopyID = Loan.CopyID "
"JOIN Book ON Book.ISBN = Copy.ISBN "
"JOIN Customer ON Customer.CustomerID = Loan.CustomerID "
"WHERE Loan.DateReturned IS NULL "
"AND Loan.DueDate < date('now') "
"ORDER BY Loan.DueDate"
)
loans = cursor.fetchall()
connection.close()
return loans
To show a stored date the British way in the form:
from datetime import datetime
datetime.strptime(loan["DueDate"], "%Y-%m-%d").strftime("%d/%m/%Y") # '22/09/2026'
[Theatre school] Counting with LEFT JOIN: spaces left in each class
A normal JOIN would leave out classes with no bookings yet. LEFT JOIN keeps every class, and COUNT only counts the bookings that exist. Cancelled bookings are left out by the extra condition on the join.
Pseudo-code:
FUNCTION getUpcomingClasses()
OPEN connection TO database
EXECUTE "SELECT ClassID, Type, ClassDate,
SpacesAvailable - number of bookings AS SpacesLeft
FROM Class LEFT JOIN Booking // keep classes with no bookings
(only bookings with status 'Booked')
WHERE ClassDate >= today
GROUP BY ClassID
ORDER BY ClassDate"
SET classes TO all matching rows
CLOSE connection
RETURN classes
END FUNCTION
Python:
def get_upcoming_classes(self):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT Class.ClassID, Class.Type, Class.ClassDate, "
"Class.SpacesAvailable - COUNT(Booking.BookingID) AS SpacesLeft "
"FROM Class "
"LEFT JOIN Booking ON Booking.ClassID = Class.ClassID "
"AND Booking.BookingStatus = 'Booked' "
"WHERE Class.ClassDate >= date('now') "
"GROUP BY Class.ClassID "
"ORDER BY Class.ClassDate"
)
classes = cursor.fetchall()
connection.close()
return classes
AS SpacesLeft names the worked-out column, so the form can read row["SpacesLeft"].
[Rugby] Totals with GROUP BY
GROUP BY makes one row per team, and SUM and COUNT work out the totals for each group:
Pseudo-code:
FUNCTION getTeamTotals(leagueID)
OPEN connection TO database
EXECUTE "SELECT team name, COUNT matches AS played,
SUM points AS points_scored, SUM tries AS tries_scored
FROM matchteam JOIN team JOIN match
WHERE match.league_id = leagueID
GROUP BY team // one row per team
ORDER BY points_scored DESC"
SET totals TO all matching rows
CLOSE connection
RETURN totals
END FUNCTION
Python:
def get_team_totals(self, league_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT team.name, COUNT(*) AS played, "
"SUM(matchteam.points) AS points_scored, "
"SUM(matchteam.tries) AS tries_scored "
"FROM matchteam "
"JOIN team ON team.team_id = matchteam.team_id "
"JOIN match ON match.match_id = matchteam.match_id "
"WHERE match.league_id = :league_id "
"GROUP BY team.team_id "
"ORDER BY points_scored DESC",
{"league_id": league_id},
)
totals = cursor.fetchall()
connection.close()
return totals
[All projects] Logging in with a username and password
A login needs three parts: storing a hash of the password when the account is made, checking a username and password against it, and a button that calls the check. These use the users table from the skeleton.
1. Hashing a password. A hash turns the password into a fixed string of characters that can’t be turned back. The same password always gives the same hash, so two hashes can be compared without the real password ever being stored. This function sits at the top of database.py, outside the class:
Pseudo-code:
FUNCTION hashPassword(password)
RETURN SHA-256 hash of password as hex text
END FUNCTION
Python:
import hashlib
def hash_password(password):
return hashlib.sha256(password.encode()).hexdigest()
2. Creating an account. The password is hashed before it goes into the database. username is UNIQUE, so a taken username raises an IntegrityError:
Pseudo-code:
FUNCTION createUser(username, email, password)
OPEN connection TO database
TRY
EXECUTE "INSERT INTO users (username, email, password_hash)
VALUES (username, email, hashPassword(password))"
COMMIT changes
RETURN ID of the new row
CATCH duplicate error // username already taken
RETURN NULL
FINALLY
CLOSE connection
END TRY
END FUNCTION
Python:
def create_user(self, username, email, password):
connection = self._connect()
cursor = connection.cursor()
try:
cursor.execute(
"INSERT INTO users (username, email, password_hash) "
"VALUES (:username, :email, :password_hash)",
{"username": username, "email": email,
"password_hash": hash_password(password)},
)
connection.commit()
return cursor.lastrowid
except sqlite3.IntegrityError:
# Username already taken
return None
finally:
connection.close()
3. Checking a login. Find the user by username, then hash what they typed and compare it with the stored hash. The method returns the user’s row if both match, or None if either is wrong:
Pseudo-code:
FUNCTION checkLogin(username, password)
OPEN connection TO database
EXECUTE "SELECT id, username, password_hash FROM users
WHERE username = username"
SET user TO the first matching row
CLOSE connection
IF user = NULL THEN
RETURN NULL // no such username
END IF
IF hashPassword(password) != user["password_hash"] THEN
RETURN NULL // wrong password
END IF
RETURN user // login OK
END FUNCTION
Python:
def check_login(self, username, password):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT id, username, password_hash FROM users "
"WHERE username = :username",
{"username": username},
)
user = cursor.fetchone() # None if no such username
connection.close()
if user is None:
return None
if hash_password(password) != user["password_hash"]:
return None # wrong password
return user
The username is found with a placeholder, and the password never appears in the SQL at all, so typing ' OR '1'='1 into either box can’t log anyone in.
4. The login button in forms.py. Check the boxes aren’t empty, ask the database, then open the main window or show an error. After three wrong attempts the button is disabled:
Pseudo-code:
PROCEDURE onLogin()
SET username TO text in username box (spaces removed)
SET password TO text in password box
IF username = "" OR password = "" THEN
OUTPUT "Please enter a username and a password."
RETURN
END IF
SET user TO db.checkLogin(username, password)
IF user = NULL THEN
SET attempts TO attempts + 1
CLEAR password box
IF attempts >= 3 THEN
OUTPUT "Too many attempts. The login is now locked."
DISABLE login button
ELSE
OUTPUT "Wrong username or password."
END IF
ELSE
HIDE login window
OPEN main window FOR user
END IF
END PROCEDURE
Python:
def on_login(self):
username = self.username_entry.get().strip()
password = self.password_entry.get()
if not username or not password:
messagebox.showwarning("Login", "Please enter a username and a password.")
return
user = self.db.check_login(username, password)
if user is None:
self.attempts += 1 # set self.attempts = 0 in __init__
self.password_entry.delete(0, tk.END)
if self.attempts >= 3:
messagebox.showerror("Login", "Too many attempts. The login is now locked.")
self.login_button.config(state="disabled")
else:
messagebox.showerror("Login", "Wrong username or password.")
else:
self.root.withdraw()
Main(self.root, self.db, user) # pass the user so Main can say hello
The skeleton’s Main only takes master and db, so add a user parameter to its __init__ and keep it as self.user. Main can then read self.user["username"] to greet them or self.user["id"] to load their records.
The error message is the same whether the username or the password was wrong, so someone guessing can’t find out which usernames exist. To make the first account, use create_user in the Python shell, as shown on the App Skeleton page.
[Record shop] Checking a login and getting a role
This is the same check as check_login above, using a Staff table with a Role column so the form can open the right menu.
Pseudo-code:
FUNCTION login(username, password)
OPEN connection TO database
EXECUTE "SELECT StaffID, Role, PasswordHash FROM Staff WHERE Username = username"
SET staff TO the first matching row
CLOSE connection
IF staff = NULL THEN
RETURN NULL // no such username
END IF
IF hash(password) != staff["PasswordHash"] THEN
RETURN NULL // wrong password
END IF
RETURN staff
END FUNCTION
Python:
def login(self, username, password):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"SELECT StaffID, Role, PasswordHash FROM Staff "
"WHERE Username = :username",
{"username": username},
)
staff = cursor.fetchone()
connection.close()
if staff is None:
return None # no such username
if hash_password(password) != staff["PasswordHash"]:
return None # wrong password
return staff # staff["Role"] picks the menu
In the form:
Pseudo-code:
SET staff TO db.login(username, password)
IF staff = NULL THEN
OUTPUT "Wrong username or password."
ELSE IF staff["Role"] = "Manager" THEN
OPEN manager menu
ELSE
OPEN staff menu
END IF
Python:
staff = self.db.login(username, password)
if staff is None:
messagebox.showerror("Login failed", "Wrong username or password.")
elif staff["Role"] == "Manager":
ManagerMenu(self.root, self.db)
else:
StaffMenu(self.root, self.db)
4. Update
An UPDATE that matches no rows isn’t an error. It just changes nothing, so always check rowcount, as update_user_email does. Adding conditions to the WHERE clause turns this into a useful check.
[Library] Returning a book
Pseudo-code:
FUNCTION returnBook(loanID)
OPEN connection TO database
EXECUTE "UPDATE Loan SET DateReturned = today
WHERE LoanID = loanID AND DateReturned IS NULL"
COMMIT changes
SET success TO (rows changed > 0) // FALSE if already returned
CLOSE connection
RETURN success
END FUNCTION
Python:
def return_book(self, loan_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"UPDATE Loan SET DateReturned = date('now') "
"WHERE LoanID = :loan_id AND DateReturned IS NULL",
{"loan_id": loan_id},
)
connection.commit()
success = cursor.rowcount > 0
connection.close()
return success
The DateReturned IS NULL condition stops a book being returned twice.
[Theatre school] Updating several fields
Separate each column = :value pair with a comma:
Pseudo-code:
FUNCTION updateParentContact(parentID, phone, email)
OPEN connection TO database
EXECUTE "UPDATE Parent SET PhoneNumber = phone, Email = email
WHERE ParentID = parentID"
COMMIT changes
SET success TO (rows changed > 0)
CLOSE connection
RETURN success
END FUNCTION
Python:
def update_parent_contact(self, parent_id, phone, email):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"UPDATE Parent SET PhoneNumber = :phone, "
"Email = :email "
"WHERE ParentID = :parent_id",
{"phone": phone, "email": email, "parent_id": parent_id},
)
connection.commit()
success = cursor.rowcount > 0
connection.close()
return success
[Progress tracker] Copying one column into another: accepting a grade
A column can be set from another column in the same row, so accepting the generated grade doesn’t need a SELECT first:
Pseudo-code:
FUNCTION acceptGrade(reviewID, teacherID)
OPEN connection TO database
EXECUTE "UPDATE ProgressReview SET FinalGrade = GeneratedGrade,
ConfirmedBy = teacherID, ConfirmedDate = today
WHERE ReviewID = reviewID"
COMMIT changes
SET success TO (rows changed > 0)
CLOSE connection
RETURN success
END FUNCTION
Python:
def accept_grade(self, review_id, teacher_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"UPDATE ProgressReview SET FinalGrade = GeneratedGrade, "
"ConfirmedBy = :teacher_id, "
"ConfirmedDate = date('now') "
"WHERE ReviewID = :review_id",
{"teacher_id": teacher_id, "review_id": review_id},
)
connection.commit()
success = cursor.rowcount > 0
connection.close()
return success
[Record shop] Changing a number: reducing stock
Quantity = Quantity - :quantity lets the database do the sum. The Quantity >= :quantity condition stops stock going below zero: if there isn’t enough, nothing changes and the method returns False.
Pseudo-code:
FUNCTION removeStock(stockID, quantity)
OPEN connection TO database
EXECUTE "UPDATE Stock SET Quantity = Quantity - quantity
WHERE StockID = stockID AND Quantity >= quantity"
COMMIT changes
SET success TO (rows changed > 0) // FALSE if not enough stock
CLOSE connection
RETURN success
END FUNCTION
Python:
def remove_stock(self, stock_id, quantity):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"UPDATE Stock SET Quantity = Quantity - :quantity "
"WHERE StockID = :stock_id "
"AND Quantity >= :quantity",
{"quantity": quantity, "stock_id": stock_id},
)
connection.commit()
success = cursor.rowcount > 0 # False if there wasn't enough stock
connection.close()
return success
5. Delete
delete_user in the skeleton is the basic pattern: DELETE FROM ... WHERE id = :id, then commit() and check rowcount. Always include a WHERE clause: without one, every row in the table is deleted.
[Progress tracker] Deleting from a link table
A link-table row is identified by both IDs together, so both go in the WHERE clause. Using only PupilID would remove the pupil from every class.
Pseudo-code:
FUNCTION removePupilFromClass(classID, pupilID)
OPEN connection TO database
EXECUTE "DELETE FROM ClassPupil
WHERE ClassID = classID AND PupilID = pupilID" // both IDs
COMMIT changes
SET success TO (rows changed > 0)
CLOSE connection
RETURN success
END FUNCTION
Python:
def remove_pupil_from_class(self, class_id, pupil_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"DELETE FROM ClassPupil "
"WHERE ClassID = :class_id "
"AND PupilID = :pupil_id",
{"class_id": class_id, "pupil_id": pupil_id},
)
connection.commit()
success = cursor.rowcount > 0
connection.close()
return success
[Theatre school] Cancelling instead of deleting
Deleting a booking loses the record of it. Changing its status keeps the history, and the spaces-left query in section 3 already ignores cancelled bookings. A library could do the same with a copy that is withdrawn.
Pseudo-code:
FUNCTION cancelBooking(bookingID)
OPEN connection TO database
EXECUTE "UPDATE Booking SET BookingStatus = 'Cancelled'
WHERE BookingID = bookingID" // keeps the record
COMMIT changes
SET success TO (rows changed > 0)
CLOSE connection
RETURN success
END FUNCTION
Python:
def cancel_booking(self, booking_id):
connection = self._connect()
cursor = connection.cursor()
cursor.execute(
"UPDATE Booking SET BookingStatus = 'Cancelled' "
"WHERE BookingID = :booking_id",
{"booking_id": booking_id},
)
connection.commit()
success = cursor.rowcount > 0
connection.close()
return success
[Any project] Deleting a row other rows depend on
With PRAGMA foreign_keys = ON (set in the skeleton’s _connect()), SQLite won’t let you delete a parent row that child rows still point at, such as a customer who still has loans. Catch the error and tell the form why:
Pseudo-code:
FUNCTION deleteCustomer(customerID)
OPEN connection TO database
TRY
EXECUTE "DELETE FROM Customer WHERE CustomerID = customerID"
COMMIT changes
RETURN (rows changed > 0)
CATCH foreign key error // customer still has loans
RETURN FALSE
FINALLY
CLOSE connection
END TRY
END FUNCTION
Python:
def delete_customer(self, customer_id):
connection = self._connect()
cursor = connection.cursor()
try:
cursor.execute(
"DELETE FROM Customer WHERE CustomerID = :customer_id",
{"customer_id": customer_id},
)
connection.commit()
return cursor.rowcount > 0
except sqlite3.IntegrityError:
# Customer still has loans, so they can't be removed
return False
finally:
connection.close()
[All projects] Asking before deleting
Deletes can’t be undone, so the form should always ask first. This is a button handler in forms.py:
Pseudo-code:
PROCEDURE onDelete()
SET selected TO the highlighted row in the table
IF nothing is selected THEN
RETURN
END IF
SET userID TO first value in the selected row
IF user answers YES to "Delete this user? This can't be undone." THEN
IF db.deleteUser(userID) THEN
REMOVE selected row FROM table
ELSE
OUTPUT "That user no longer exists."
END IF
END IF
END PROCEDURE
Python:
def on_delete(self):
selected = self.tree.focus()
if not selected:
return
user_id = self.tree.item(selected, "values")[0]
if messagebox.askyesno("Delete user", "Delete this user? This can't be undone.",
parent=self.win):
if self.db.delete_user(user_id):
self.tree.delete(selected)
else:
messagebox.showerror("Delete user", "That user no longer exists.",
parent=self.win)
6. Common errors
When a query fails, print the SQL and the dictionary together to see exactly what was sent: print(sql, values).
| Error | Usual cause | Fix |
|---|---|---|
OperationalError: no such column: … |
A misspelt column name, or a placeholder written without its :. |
Check the spelling against your CREATE TABLE. |
OperationalError: near "…": syntax error |
A missing comma, or a missing space between pieces of the string. | print() the SQL and check where the pieces join. |
ProgrammingError: You did not supply a value for binding parameter :name |
The dictionary has no key matching a placeholder. | Make the key and the placeholder the same name. |
IntegrityError: UNIQUE constraint failed |
A duplicate value, such as a username or a link-table pair that already exists. | Catch it with try/except and tell the user. |
IntegrityError: NOT NULL constraint failed |
A required field was left empty. | Add a presence check in the form. |
IntegrityError: FOREIGN KEY constraint failed |
An ID that doesn’t exist in the parent table, or deleting a parent that still has children. | Check the ID exists, or catch it and explain. |
OperationalError: no such table |
A misspelt table name, or the wrong database file. | Check the name and the DB_FILE path. |
OperationalError: database is locked |
A connection was left open without committing. | Make sure every method calls close(). |
| No error, but the change isn’t saved | commit() was missed after an INSERT, UPDATE or DELETE. |
Add connection.commit(). |
| A value with an apostrophe (O’Brien) breaks the query | The value was joined into the SQL with +. |
Use a :name placeholder instead. |
7. Quick reference
| Task | SQL | Commit? | Return |
|---|---|---|---|
| Add a row | INSERT INTO table (a, b) VALUES (:a, :b) |
Yes | cursor.lastrowid |
| Get one row | SELECT ... FROM table WHERE id = :id |
No | cursor.fetchone() |
| Get many rows | SELECT ... FROM table ORDER BY ... |
No | cursor.fetchall() |
| Search | SELECT ... WHERE name LIKE :pattern |
No | cursor.fetchall() |
| Change a row | UPDATE table SET a = :a WHERE id = :id |
Yes | cursor.rowcount > 0 |
| Remove a row | DELETE FROM table WHERE id = :id |
Yes | cursor.rowcount > 0 |
For every method, check: a :placeholder for every value with a matching key in the dictionary, spaces between the pieces of the string, commit() after any change, close() at the end, and a return value the form can use.