One guest at Tembo Hotel & Suites checked out three days before checking in. The spreadsheet says so. It also spells Nairobi as Nairobi and as NAIROBI, files a town called Thikax under guest cities, and writes January dates as 10/01/2024, 01-12-2024 and 28-01-24, three formats in one column.
That spreadsheet is the whole booking history of a mid-range business hotel in Nairobi, open since 2023. The Hotel Director wants to know which rooms earn the most, which months are busiest, how the staff are doing and whether guests are happy. Nobody can answer those questions from a file like this. Any total you add up is wrong by an amount you can't measure.
This article follows the project from the raw CSV to the finished answers. I load the file into PostgreSQL, fix the damage one defect at a time, and then run the six analyses the Director asked for. I ran every query below, in order, against the real file on PostgreSQL 16. The output under each query is what came back, laid out the way DBeaver shows it: a result grid for SELECT, a statistics line for everything else.
The brief in plain words
The Director's message boils down to four worries: money, busy periods, staff and guest happiness. The brief turns them into six groups of questions:
- Revenue by month, by room type and by payment method.
- Which room types are booked most, and how many nights a guest stays on average.
- The top 10 cities guests come from, and the average rating per room type.
- Which staff member handled the most bookings, and which department earns the most.
- Month-over-month revenue growth (with a window function), and the busiest and quietest months.
- The cancellation rate per room type, and the revenue lost to cancellations and no-shows.
The file is tembo_hotel_dirty.csv: 286 rows and 20 columns, one row per booking. Each row carries the guest, the room, the dates, the staff member who handled it, how the guest paid, any extra service (spa, laundry, airport pickup) and the guest's rating.
The plan: register first, get a room second
A guest doesn't walk straight to a room. They stop at the front desk, where staff check the details, and only then get a key. The database work follows the same order:
tembo_hotel_dirty.csv | v staging_bookings every column is TEXT, so nothing is rejected on arrival | fix each defect with UPDATE / DELETE v clean_bookings real types and CHECK constraints, so bad data can't get in | v v_clean_bookings a view that adds a month column for the analysis | v six business questions The staging table exists because a strict table would refuse the file at the first KES 34000 in a numeric column. With everything as text, the load always succeeds, and the cleaning happens where I can watch it, one column at a time.
Step 1: Ingest the CSV without losing a row
I keep the project in its own schema so it never collides with anything else in the database.
CREATE SCHEMA IF NOT EXISTS tembo; SET search_path TO tembo; DROP TABLE IF EXISTS tembo.staging_bookings; CREATE TABLE tembo.staging_bookings ( booking_id TEXT, guest_name TEXT, guest_phone TEXT, guest_city TEXT, guest_nationality TEXT, room_no TEXT, room_type TEXT, room_rate_per_night TEXT, check_in_date TEXT, check_out_date TEXT, nights_stayed TEXT, staff_name TEXT, staff_department TEXT, staff_salary TEXT, payment_method TEXT, booking_status TEXT, total_amount TEXT, service_used TEXT, service_price TEXT, guest_rating TEXT ); Statistics tab: Updated Rows = 0
In DBeaver you can right-click the table and choose Import Data, then point it at the CSV. If you prefer a script, PostgreSQL's COPY does the same job in one line:
COPY tembo.staging_bookings FROM '/path/to/tembo_hotel_dirty.csv' WITH (FORMAT csv, HEADER true); Statistics tab: Updated Rows = 286
COPY reads the file on the database server, so the path must be visible to the server and your role needs the right privileges. From a client machine, use \copy in psql or the DBeaver wizard.
Next, count the rows and look at the first few. This is the sanity check I run after every load.
SELECT COUNT(*) AS rows_loaded FROM tembo.staging_bookings; Result grid: 1 row fetched
| rows_loaded |
|---|
| 286 |
SELECT booking_id, guest_name, guest_phone, guest_city, room_type, check_in_date, total_amount FROM tembo.staging_bookings LIMIT 8; Result grid: 8 rows fetched
| booking_id | guest_name | guest_phone | guest_city | room_type | check_in_date | total_amount |
|---|---|---|---|---|---|---|
| BK0001 | ALICE MWANGI | 0712345678 | Nairobi | Standard | 2024-01-05 | 6000 |
| BK0002 | brian otieno | 0723456789 | Mombasa | Standard | 2024-01-06 | 5500 |
| BK0003 | Carol Wanjiku | 0734567890 | Kisumu | Deluxe | 2024-01-07 | 25500 |
| BK0004 | David Kamau | 0745-678-901 | Nairobi | Deluxe | 2024-01-08 | 18200 |
| BK0005 | Esther Njoroge | +254756789012 | Nakuru | Suite | 2024-01-09 | 60000 |
| BK0006 | Felix Hassan | 0767890123 | Nairobi | Standard | 10/01/2024 | 27500 |
| BK0007 | Grace Achieng | 0778901234 | Eldoret | Deluxe | 01-12-2024 | 43000 |
| BK0008 | Henry Korir | 0789012345 | [NULL] | Suite | 2024-01-13 | 30000 |
286 rows, as promised. Look at the grid, though. Row 3 has stray spaces around a name, row 4 has dashes in a phone number, row 5 has a +254 prefix instead of a leading zero, row 7 has a date in month-day-year order, and row 8 has an empty city. We are just getting started.
Step 2: Audit the damage before touching anything
I don't fix by feel. One query counts every kind of damage I can test for, so I know the size of the job and can prove afterwards that it's done. Each line is a rule the clean data must obey, tested against the raw text.
SELECT 'Duplicate booking_id rows' AS problem, COUNT(*) - COUNT(DISTINCT booking_id) AS rows_affected FROM tembo.staging_bookings UNION ALL SELECT 'Names with stray spaces or odd casing', COUNT(*) FROM tembo.staging_bookings WHERE guest_name <> INITCAP(TRIM(guest_name)) UNION ALL SELECT 'Phones with dashes, +254 or padding', COUNT(*) FROM tembo.staging_bookings WHERE guest_phone !~ '^0[0-9]{9}$' UNION ALL SELECT 'Cities blank, misspelt or oddly cased', COUNT(*) FROM tembo.staging_bookings WHERE guest_city IS NULL OR guest_city !~ '^[A-Z][a-z]+$' OR guest_city = 'Thikax' UNION ALL SELECT 'Nationality casing', COUNT(*) FROM tembo.staging_bookings WHERE guest_nationality <> 'Kenyan' UNION ALL SELECT 'Room type aliases (Std, DLX, lower case)', COUNT(*) FROM tembo.staging_bookings WHERE room_type NOT IN ('Standard','Deluxe','Suite','Penthouse') UNION ALL SELECT 'Payment method aliases', COUNT(*) FROM tembo.staging_bookings WHERE payment_method NOT IN ('Cash','Card','Bank Transfer','M-Pesa') UNION ALL SELECT 'Booking status casing', COUNT(*) FROM tembo.staging_bookings WHERE booking_status NOT IN ('Checked Out','Cancelled','No Show') UNION ALL SELECT 'Dates not in YYYY-MM-DD', COUNT(*) FROM tembo.staging_bookings WHERE check_in_date !~ '^\d{4}-\d{2}-\d{2}$' OR check_out_date !~ '^\d{4}-\d{2}-\d{2}$' UNION ALL SELECT 'Check-out before check-in', COUNT(*) FROM tembo.staging_bookings WHERE check_in_date ~ '^\d{4}-\d{2}-\d{2}$' AND check_out_date ~ '^\d{4}-\d{2}-\d{2}$' AND check_out_date < check_in_date UNION ALL SELECT 'Nights stayed below 1', COUNT(*) FROM tembo.staging_bookings WHERE nights_stayed::INT < 1 UNION ALL SELECT 'Staff salary unreadable', COUNT(*) FROM tembo.staging_bookings WHERE staff_salary !~ '^\d+$' UNION ALL SELECT 'Total amount blank or not a plain number', COUNT(*) FROM tembo.staging_bookings WHERE total_amount IS NULL OR total_amount !~ '^\d+$' UNION ALL SELECT 'Rating padded or outside 1 to 5', COUNT(*) FROM tembo.staging_bookings WHERE guest_rating !~ '^[1-5]$'; Result grid: 14 rows fetched
| problem | rows_affected |
|---|---|
| Duplicate booking_id rows | 1 |
| Names with stray spaces or odd casing | 45 |
| Phones with dashes, +254 or padding | 29 |
| Cities blank, misspelt or oddly cased | 45 |
| Nationality casing | 1 |
| Room type aliases (Std, DLX, lower case) | 29 |
| Payment method aliases | 15 |
| Booking status casing | 15 |
| Dates not in YYYY-MM-DD | 44 |
| Check-out before check-in | 2 |
| Nights stayed below 1 | 1 |
| Staff salary unreadable | 14 |
| Total amount blank or not a plain number | 30 |
| Rating padded or outside 1 to 5 | 17 |
Fourteen kinds of damage. (Missing phone numbers and ratings are not on the list, because a value that was never recorded is a gap, not a defect.) Some are cosmetic (casing), some silently change an answer (a duplicate booking counts twice), and some make an answer impossible (a total that is just a comma). The !~ operator means "does not match this regular expression", which is the fastest way to find everything that breaks a rule I can describe.
Step 3: Fix the data, one defect at a time
Every fix follows the same rhythm. Look at the problem, write the UPDATE, then run a check that proves it worked. I keep the WHERE clause on each update so the "Updated Rows" count tells me how many rows I really touched. An update with no WHERE clause always reports 285, which teaches you nothing.
3.1 Duplicate bookings
SELECT booking_id, guest_name, check_in_date, total_amount, COUNT(*) AS copies FROM tembo.staging_bookings GROUP BY booking_id, guest_name, check_in_date, total_amount HAVING COUNT(*) > 1; Result grid: 1 row fetched
| booking_id | guest_name | check_in_date | total_amount | copies |
|---|---|---|---|---|
| BK0006 | Felix Hassan | 10/01/2024 | 27500 | 2 |
Booking BK0006 appears twice, identical in every column. Revenue counted twice is the classic quiet error. In PostgreSQL every row has a hidden physical address called ctid. I keep the lowest address for each booking_id and delete the rest.
DELETE FROM tembo.staging_bookings WHERE ctid NOT IN ( SELECT MIN(ctid) FROM tembo.staging_bookings GROUP BY booking_id ); Statistics tab: Updated Rows = 1
SELECT COUNT(*) AS rows_left, COUNT(DISTINCT booking_id) AS unique_ids FROM tembo.staging_bookings; Result grid: 1 row fetched
| rows_left | unique_ids |
|---|---|
| 285 | 285 |
285 rows and 285 unique IDs. (MIN(ctid) needs PostgreSQL 14 or newer. On older versions, use ROW_NUMBER() in a CTE.)
3.2 Guest names
The LENGTH column exposes what the eye can't see: trailing spaces.
SELECT guest_name, LENGTH(guest_name) AS chars, COUNT(*) AS rows_affected FROM tembo.staging_bookings WHERE guest_name <> INITCAP(TRIM(guest_name)) GROUP BY guest_name ORDER BY rows_affected DESC; Result grid: 5 rows fetched
| guest_name | chars | rows_affected |
|---|---|---|
| ALICE MWANGI | 12 | 15 |
| brian otieno | 12 | 14 |
| Carol Wanjiku | 17 | 14 |
| grace wanjiru | 13 | 1 |
| PETER MWANGI | 12 | 1 |
INITCAP capitalises each word and lower-cases the rest. TRIM strips the spaces.
UPDATE tembo.staging_bookings SET guest_name = INITCAP(TRIM(guest_name)) WHERE guest_name <> INITCAP(TRIM(guest_name)); Statistics tab: Updated Rows = 45
The check is the same query as before. It should return nothing.
SELECT guest_name, LENGTH(guest_name) AS chars, COUNT(*) AS rows_affected FROM tembo.staging_bookings WHERE guest_name <> INITCAP(TRIM(guest_name)) GROUP BY guest_name; Result grid: 0 rows fetched
| guest_name | chars | rows_affected |
|---|
3.3 Phone numbers
QUOTE_LITERAL wraps each value in quotes, so blanks and padding become visible.
SELECT QUOTE_LITERAL(guest_phone) AS phone_as_typed, COUNT(*) AS rows_affected FROM tembo.staging_bookings WHERE guest_phone !~ '^0[0-9]{9}$' GROUP BY guest_phone ORDER BY rows_affected DESC; Result grid: 3 rows fetched
| phone_as_typed | rows_affected |
|---|---|
| '+254756789012' | 14 |
| '0745-678-901' | 14 |
| ' 0700000004 ' | 1 |
Three shapes: dashes, a +254 prefix and padding. The 14 blank phones don't appear, because COPY loads an empty CSV field as NULL. Some import tools load an empty string instead, so the CASE below turns those into NULL as well. The target is the local ten-digit form, 07xxxxxxxx: strip everything that isn't a digit, and turn a leading 254 into 0.
UPDATE tembo.staging_bookings SET guest_phone = CASE WHEN NULLIF(TRIM(guest_phone), '') IS NULL THEN NULL WHEN TRIM(guest_phone) LIKE '+254%' THEN '0' || SUBSTRING(REGEXP_REPLACE(guest_phone, '[^0-9]', '', 'g') FROM 4) ELSE REGEXP_REPLACE(guest_phone, '[^0-9]', '', 'g') END WHERE guest_phone !~ '^0[0-9]{9}$'; Statistics tab: Updated Rows = 29
SELECT COUNT(*) FILTER (WHERE guest_phone IS NULL) AS missing_phones, COUNT(*) FILTER (WHERE guest_phone !~ '^0[0-9]{9}$') AS malformed_phones FROM tembo.staging_bookings; Result grid: 1 row fetched
| missing_phones | malformed_phones |
|---|---|
| 14 | 0 |
3.4 Cities
SELECT QUOTE_LITERAL(guest_city) AS city_as_typed, COUNT(*) AS bookings FROM tembo.staging_bookings GROUP BY guest_city ORDER BY bookings DESC; Result grid: 12 rows fetched
| city_as_typed | bookings |
|---|---|
| 'Nairobi' | 114 |
| 'Eldoret' | 28 |
| 'Kisumu' | 27 |
| 'Mombasa' | 15 |
| 'NAIROBI' | 15 |
| 'Nakuru' | 14 |
| 'Machakos' | 14 |
| 'Nyeri' | 14 |
| 'Meru' | 14 |
| [NULL] | 14 |
| 'Thikax' | 14 |
| 'kisumu' | 2 |
Nairobi has three spellings. Thikax is a typo for Thika, which is the only reading that fits: it appears exactly as often as the other small towns and no such place exists. That is an assumption, and I say so here so a reader can overrule it. Blank cities become Unknown rather than staying NULL, so the label is visible in every report and the Director can see how much is missing.
UPDATE tembo.staging_bookings SET guest_city = CASE WHEN NULLIF(TRIM(guest_city), '') IS NULL THEN 'Unknown' WHEN LOWER(TRIM(guest_city)) = 'thikax' THEN 'Thika' ELSE INITCAP(TRIM(guest_city)) END WHERE guest_city IS NULL OR guest_city !~ '^[A-Z][a-z]+$' OR guest_city = 'Thikax'; Statistics tab: Updated Rows = 45
SELECT guest_city, COUNT(*) AS bookings FROM tembo.staging_bookings GROUP BY guest_city ORDER BY bookings DESC; Result grid: 10 rows fetched
| guest_city | bookings |
|---|---|
| Nairobi | 129 |
| Kisumu | 29 |
| Eldoret | 28 |
| Mombasa | 15 |
| Meru | 14 |
| Unknown | 14 |
| Nakuru | 14 |
| Thika | 14 |
| Machakos | 14 |
| Nyeri | 14 |
3.5 Short vocabularies: room type, payment, status, nationality
These four columns hold a handful of allowed values each, so one pattern fits all: map every known variant to one spelling. The room types hide the most, since Std and DLX are abbreviations that no casing function can repair.
UPDATE tembo.staging_bookings SET room_type = CASE WHEN UPPER(TRIM(room_type)) IN ('STANDARD', 'STD') THEN 'Standard' WHEN UPPER(TRIM(room_type)) IN ('DELUXE', 'DLX') THEN 'Deluxe' WHEN UPPER(TRIM(room_type)) = 'SUITE' THEN 'Suite' WHEN UPPER(TRIM(room_type)) = 'PENTHOUSE' THEN 'Penthouse' ELSE room_type END WHERE room_type NOT IN ('Standard', 'Deluxe', 'Suite', 'Penthouse'); Statistics tab: Updated Rows = 29
UPDATE tembo.staging_bookings SET payment_method = 'M-Pesa' WHERE UPPER(TRIM(payment_method)) IN ('MPESA', 'M PESA') AND payment_method <> 'M-Pesa'; Statistics tab: Updated Rows = 15
UPDATE tembo.staging_bookings SET booking_status = INITCAP(TRIM(booking_status)) WHERE booking_status <> INITCAP(TRIM(booking_status)); Statistics tab: Updated Rows = 15
UPDATE tembo.staging_bookings SET guest_nationality = INITCAP(TRIM(guest_nationality)) WHERE guest_nationality <> INITCAP(TRIM(guest_nationality)); Statistics tab: Updated Rows = 1
One query verifies all four columns at once:
SELECT 'room_type' AS column_name, room_type AS value, COUNT(*) AS bookings FROM tembo.staging_bookings GROUP BY room_type UNION ALL SELECT 'payment_method', payment_method, COUNT(*) FROM tembo.staging_bookings GROUP BY payment_method UNION ALL SELECT 'booking_status', booking_status, COUNT(*) FROM tembo.staging_bookings GROUP BY booking_status UNION ALL SELECT 'guest_nationality', guest_nationality, COUNT(*) FROM tembo.staging_bookings GROUP BY guest_nationality ORDER BY column_name, bookings DESC; Result grid: 12 rows fetched
| column_name | value | bookings |
|---|---|---|
| booking_status | Checked Out | 253 |
| booking_status | Cancelled | 23 |
| booking_status | No Show | 9 |
| guest_nationality | Kenyan | 285 |
| payment_method | M-Pesa | 74 |
| payment_method | Cash | 72 |
| payment_method | Card | 70 |
| payment_method | Bank Transfer | 69 |
| room_type | Standard | 114 |
| room_type | Deluxe | 90 |
| room_type | Suite | 56 |
| room_type | Penthouse | 25 |
3.6 Dates, the hard one
Dates need the most care, because a wrong guess is invisible. 05/03/2024 is either 5 March or 3 May, and nothing in the text says which. First, sort the check-in dates by shape:
SELECT CASE WHEN check_in_date ~ '^\d{4}-\d{2}-\d{2}$' THEN 'YYYY-MM-DD' WHEN check_in_date ~ '^\d{2}/\d{2}/\d{4}$' THEN 'NN/NN/YYYY' WHEN check_in_date ~ '^\d{2}-\d{2}-\d{2}$' THEN 'NN-NN-YY' WHEN check_in_date ~ '^\d{2}-\d{2}-\d{4}$' THEN 'NN-NN-YYYY' Source:https://dev.to/david_mwandairo/cleaning-and-analyzing-tembo-hotels-bookings-41o5