Stefan Koch
Stefan Koch
Learn Databricks SQL - Part 1 - Why, and the data we'll use

Learn Databricks SQL - Part 1 - Why, and the data we'll use

Stefan Koch Stefan Koch
· · 18 min read

A small bike rental in Lucerne has a table with 30 rentals. What was the most recent rental of every customer?

SELECT
  customer_id,
  rental_id,
  started_at,
  price_chf
FROM rentals
QUALIFY row_number() OVER (PARTITION BY customer_id ORDER BY started_at DESC) = 1
ORDER BY customer_id
customer_idintrental_idintstarted_attimestampprice_chfdecimal
1262026-03-30 05:42:003.00
2252026-03-30 06:03:005.50
3302026-03-31 17:55:00NULL
4182026-03-27 23:40:004.00
5272026-03-30 12:30:003.00
6232026-03-29 00:50:003.00
7292026-03-31 16:40:009.00
8282026-03-31 06:15:005.50
9242026-03-29 13:05:005.00
99162026-03-26 14:00:002.50

One statement, and no subquery. QUALIFY filters on a window function the way WHERE filters on a column. Without it, the window function moves into a subquery and the filter outside:

SELECT
  customer_id,
  rental_id,
  started_at,
  price_chf
FROM (
  SELECT
    customer_id,
    rental_id,
    started_at,
    price_chf,
    row_number() OVER (PARTITION BY customer_id ORDER BY started_at DESC) AS rn
  FROM rentals
) AS ranked
WHERE rn = 1
ORDER BY customer_id

I ran both. They return the same ten rows.

Two rows of the result are odd. The last rental of customer 3 has no price. And customer 99 has a rental, but the bike rental has no customer 99. Both are on purpose, and this part explains why.

This series goes through the SQL of Databricks like this: statement by statement, every one run on the same small tables, every one with what came back.

Why this series

Databricks documents its SQL in the SQL language reference. On 11 November 2026, its index linked to about 200 pages of statements and clauses, and the built-in functions are a long list of their own.

I write SQL on Databricks all the time. And most of the time, I write the same few statements, the same way, again and again. They work, so I never look further. Most of those 200 pages I have never opened.

This series is my reason to open them. I want to work through the reference properly, try the statements I never use, and find out what the ones I use every day can do that I don’t know about. It’s practice for me, first of all.

Writing it down forces me to be exact: an example in an article has to run, and I have to understand why it returns what it returns. And if I write it down anyway, someone else can use it too.

The reference itself doesn’t make that easy. It’s complete, and it’s a reference: syntax, parameters, and examples on tables that change from page to page. It tells me what a statement accepts. I want to see what it does – with every clause and every option, on data I know, including the cases where it fails.

So the rules of the series are:

  • One topic per part. A part covers one statement or one clause, with every variation the reference describes.
  • Everything is run. Every statement in an article ran on a SQL warehouse. The result tables are generated from what the warehouse returned. I don’t type results.
  • Errors are examples too. If something doesn’t work, the article shows the statement and the error message.
  • The same data everywhere. Six small tables. After a few parts you know every row.
  • What I don’t know, I say. If I couldn’t test something, the part says so.

What “Databricks SQL” means here

The reference calls itself “a SQL command reference for Databricks SQL and Databricks Runtime”. Databricks SQL is the part of the platform with the SQL warehouses and the SQL editor. Databricks Runtime is what runs on clusters.

In this series, Databricks SQL means: SQL sent to a SQL warehouse. That’s where I ran every example. Most of the language is the same on both sides, and the pages of the reference say where a statement applies and from which version. The page of the QUALIFY clause from above, for example, says Databricks SQL and Databricks Runtime 10.4 LTS and above. Every part has a section “Where it runs” with these notes.

What you need

A Databricks workspace with a SQL warehouse. Databricks Free Edition is enough: it costs nothing and comes with one SQL warehouse, limited to the size 2X-Small. It has a quota. If you exceed it, the compute is shut down for the rest of the day, and the data stays (Free Edition limitations).

I run the series on a Free Edition workspace. I don’t know whether every statement runs there. Where one doesn’t, the part says so.

The data

The data is a fictional bike rental in Lucerne, in a schema called velo in the catalog sql_playground. Six tables, the largest with 30 rows:

Entity-relationship diagram of the schema velo in crow's foot notation. Six tables with their columns, types, primary and foreign keys: price_changes (6 rows), bikes (12 rows), rentals (30 rows), stations (6 rows), customers (10 rows) and rentals_raw (30 rows). A bike and a customer each have zero or many rentals, a rental has exactly one bike and one customer. A rental has exactly one start station and zero or one end station. A bike has zero or one home station. Dashed lines connect bikes with price_changes and rentals_raw with rentals: same values, no key. A box lists six rows that are wrong on purpose

Read a line from one end to the other: a bike has zero or many rentals, a rental has exactly one bike. A rental has exactly one start station, but zero or one end station, because a rental that is still running has none. price_changes has only a dashed line: a bike finds its prices by its type and the date, and bike_type alone is not a key there.

The keys are the intended ones. The tables declare no primary or foreign keys, so nothing stops a rental from pointing to a customer that doesn’t exist. One does.

The tables are small so that every row fits on the screen. When a join drops a row or a filter loses a NULL, you can see which one.

And some rows are wrong, missing or odd on purpose. Real data has such rows, and they are where SQL gets interesting. Here are all six tables, with the rows to remember.

stations

SELECT *
FROM stations
ORDER BY station_id
station_idintnamestringpostal_codeintlatitudedoublelongitudedoublecapacityintopened_ondate
1Bahnhof600347.05028.3102242022-05-01
2Schwanenplatz600447.05338.3089162022-05-01
3Verkehrshaus600647.05398.3373202023-04-01
4Allmend600547.03518.3032122023-04-01
5Kantonsspital600447.05788.298102024-06-15
6Littau601447.05318.2571NULL2026-04-15
  • The rental is fictional, the places are real. Every station has the coordinates of the public transport stop of the same name in Lucerne, from the stop search of transport.opendata.ch, and the postal code at that point, from the official index of localities of swisstopo.
  • Station 6, Littau, opens on 15 April 2026, after the week of the rentals. It has no capacity yet, no bikes and no rentals.

bikes

SELECT *
FROM bikes
ORDER BY bike_id
bike_idintmodelstringbike_typestringpurchased_ondatepurchase_pricedecimalhome_station_idintstatusstring
1Tour 3city2023-04-12890.001active
2Tour 3city2023-04-12890.002active
3Tour 3city2024-03-01940.001active
4Urban Scity2024-03-011120.004active
5Urban Scity2025-02-171150.002active
6Volt E1e-bike2024-05-203400.003active
7Volt E1e-bike2022-06-033250.001retired
8Volt E2e-bike2025-02-173890.003active
9Volt E2e-bike2025-02-173890.005active
10Cargo Lcargo2024-09-095600.004active
11Cargo Lcargo2024-09-095600.00NULLmaintenance
12Volt E2e-bike2026-03-303990.003active
  • Bike 11 is in maintenance and has no home station.
  • Bike 12 was bought on 30 March 2026 and has never been rented.
  • Bike 7 is retired.

customers

SELECT *
FROM customers
ORDER BY customer_id
customer_idintfirst_namestringlast_namestringemailstringcitystringsigned_up_ondateplanstring
1LucaKeller[email protected]Luzern2024-05-02plus
2MiaSchmid[email protected]Luzern2024-08-19plus
3NoahWeber[email protected]Kriens2025-01-11basic
4AnnaMeier[email protected]luzern2025-03-07basic
5ElenaFischerNULLLuzern2025-06-23basic
6JonasMüller[email protected]LUZERN2025-09-01plus
7SofiaBrunner[email protected]Emmen2025-11-15NULL
8NULLStadtkurier GmbH[email protected]Luzern2024-02-01business
9ANNAMEIER [email protected] Luzern2026-01-20plus
10TimBaumann[email protected]Küssnacht2026-03-15basic
  • Customers 4 and 9 are the same person, signed up twice. The name is in a different case, the email too, and the second email has a space before and after it.
  • Customer 5 has no email, customer 7 no plan.
  • Customer 8 is a company and has no first name.
  • The city Luzern is spelled three ways: Luzern, luzern and LUZERN.
  • Customer 10 signed up and never rented a bike.

price_changes

SELECT *
FROM price_changes
ORDER BY valid_from, bike_type
bike_typestringvalid_fromdateunlock_feedecimalprice_per_30_mindecimal
cargo2026-01-012.004.00
city2026-01-011.001.50
e-bike2026-01-011.503.00
cargo2026-03-282.004.50
city2026-03-281.002.00
e-bike2026-03-282.003.50

A rental costs the unlock fee plus the price for every started 30 minutes. The prices went up on 28 March 2026, in the middle of the week of the rentals. Which price a rental gets depends on the day it started.

rentals

SELECT *
FROM rentals
ORDER BY rental_id
rental_idintbike_idintcustomer_idintstart_station_idintend_station_idintstarted_attimestampended_attimestampdistance_kmdecimalprice_chfdecimalpayment_methodstring
111122026-03-23 06:42:002026-03-23 07:00:003.22.50card
262312026-03-23 07:05:002026-03-23 07:29:006.14.50twint
323252026-03-23 11:30:002026-03-23 12:11:005.44.00card
4105442026-03-23 13:15:002026-03-23 14:50:007.818.00twint
574132026-03-24 06:50:002026-03-24 07:17:006.44.50card
631122026-03-24 06:44:002026-03-24 07:00:003.12.50card
786512026-03-24 16:20:002026-03-24 16:42:004.04.50twint
817222026-03-24 18:05:002026-03-24 19:08:009.55.50card
962312026-03-25 07:02:002026-03-25 07:27:006.04.50twint
1048412026-03-25 07:30:002026-03-25 08:03:007.24.00invoice
11115452026-03-25 09:10:002026-03-25 11:10:0011.318.00twint
1229132026-03-25 17:45:002026-03-25 18:14:006.62.50card
1393522026-03-26 12:10:002026-03-26 12:24:002.24.50card
1431122026-03-26 06:41:002026-03-26 06:58:003.22.50card
1576152026-03-26 21:15:002026-03-26 21:34:003.54.50twint
16599212026-03-26 14:00:002026-03-26 14:12:001.92.50card
1782312026-03-27 07:04:002026-03-27 07:30:006.14.50twint
1814142026-03-27 23:40:002026-03-28 00:25:008.04.00card
19107232026-03-28 10:00:002026-03-28 12:30:0014.224.50card
2068412026-03-28 08:15:002026-03-28 08:46:007.09.00invoice
2121122026-03-28 09:30:002026-03-28 09:45:003.00.00NULL
2293552026-03-28 15:20:002026-03-28 16:48:0012.412.50card
2346152026-03-29 00:50:002026-03-29 01:20:004.13.00twint
2439322026-03-29 13:05:002026-03-29 13:57:008.85.00card
2582312026-03-30 06:03:002026-03-30 06:28:006.05.50twint
2611122026-03-30 05:42:002026-03-30 05:59:003.13.00card
2755442026-03-30 12:30:002026-03-30 12:50:002.73.00twint
2868412026-03-31 06:15:002026-03-31 06:45:007.15.50invoice
2997212026-03-31 16:40:002026-03-31 17:16:005.99.00card
30235NULL2026-03-31 17:55:00NULLNULLNULLNULL

One week, 23 to 31 March 2026.

  • Rental 16 belongs to customer 99. There is no customer 99.
  • Rental 18 starts before midnight and ends after it.
  • Rental 21 was free: a promotion. The price is 0, and the payment method is NULL. 0 and NULL are not the same thing, and this row has both.
  • Rental 23 crosses the switch to summer time. More about it below.
  • Rental 28 took exactly 30 minutes: one started half hour, or two?
  • Rental 30 is still running. It has no end, no distance and no price.

rentals_raw

SELECT *
FROM rentals_raw
ORDER BY raw:rental_id::int
3 of 30 rows
rawstring
{"rental_id":1,"bike":{"id":1,"type":"city"},"customer_id":1,"start":{"station_id":1,"ts":"2026-03-23T06:42:00Z"},"end":{"station_id":2,"ts":"2026-03-23T07:00:00Z"},"distance_km":3.2,"price_chf":2.5,"payment":"card","tags":["commute"],"app":{"os":"iOS","version":"2.4.1"}}
{"rental_id":2,"bike":{"id":6,"type":"e-bike"},"customer_id":2,"start":{"station_id":3,"ts":"2026-03-23T07:05:00Z"},"end":{"station_id":1,"ts":"2026-03-23T07:29:00Z"},"distance_km":6.1,"price_chf":4.5,"payment":"twint","tags":["commute"],"app":{"os":"Android","version":"2.4.1"}}
{"rental_id":3,"bike":{"id":2,"type":"city"},"customer_id":3,"start":{"station_id":2,"ts":"2026-03-23T11:30:00Z"},"end":{"station_id":5,"ts":"2026-03-23T12:11:00Z"},"distance_km":5.4,"price_chf":4.0,"payment":"card","tags":["leisure"],"app":{"os":"iOS","version":"2.3.0"}}

The same 30 rentals as the app would send them: one JSON document per rental, in a single string column. The documents are nested, and they have two things the table doesn’t: a list of tags, sometimes empty, and details about the app.

Time

All timestamps in the tables are UTC. How a timestamp is displayed depends on the time zone of the session. On my warehouse, that’s UTC too:

SELECT current_timezone() AS session_time_zone
session_time_zonestring
Etc/UTC

Lucerne is not in UTC. Its time zone is Europe/Zurich: Central European Time (CET), one hour ahead of UTC, in winter, and Central European Summer Time (CEST), two hours ahead, in summer. That makes two rentals interesting. Here they are in UTC and in local time:

SELECT
  rental_id,
  started_at,
  ended_at,
  from_utc_timestamp(started_at, 'Europe/Zurich') AS started_local,
  from_utc_timestamp(ended_at, 'Europe/Zurich') AS ended_local
FROM rentals
WHERE rental_id IN (18, 23)
ORDER BY rental_id
rental_idintstarted_attimestampended_attimestampstarted_localtimestampended_localtimestamp
182026-03-27 23:40:002026-03-28 00:25:002026-03-28 00:40:002026-03-28 01:25:00
232026-03-29 00:50:002026-03-29 01:20:002026-03-29 01:50:002026-03-29 03:20:00

Rental 18 starts on 27 March in UTC and on 28 March in Lucerne. 28 March is the day the prices went up. Which day counts is a decision, and the data has to make it somewhere. I calculated the prices with the UTC date.

Rental 23 started at 01:50 local time, in CET, and ended at 03:20, in CEST. It took 30 minutes. In that night, the clocks in Switzerland went from 02:00 to 03:00.

Get the data

Everything is in a public repository: github.com/stefanko-ch/learn-databricks-sql. One script, setup/00_velo.sql, creates a catalog of its own, sql_playground, the schema velo in it, and the six tables with CREATE OR REPLACE TABLE and INSERT ... VALUES. So the data is in the same place for everyone: sql_playground.velo. The rows are written out in the script. Nothing is random, so you get exactly the rows above. And because of CREATE OR REPLACE, running it again resets the data.

This is how I ran it, in a Free Edition workspace with nothing in it.

1. Get the repository into the workspace. In the sidebar, open Workspace, then Create > Git folder. Paste the URL of the repository. Databricks fills in the provider and the folder name.

The dialog Create Git folder: the Git repository URL is https://github.com/stefanko-ch/learn-databricks-sql, the Git provider GitHub and the Git folder name learn-databricks-sql are filled in

A few seconds later the repository is a folder in your workspace. The repository is public: I had no Git credentials set up in the workspace, and it didn’t ask for any.

The Git folder learn-databricks-sql in the workspace, on the branch main, with the folders parts and setup and the files LICENSE, README.md and STYLE.md

2. Open the script and choose the SQL warehouse. Open setup, then 00_velo.sql. The file opens in the SQL editor. Look at the compute selector at the top right before you run it: for me, the file was attached to “Serverless”, not to a SQL warehouse. Open the selector and choose your warehouse. On Free Edition there is one, the Serverless Starter Warehouse.

The compute selector of the file 00_velo.sql, open: Serverless is selected, below it under Active resources the Serverless Starter Warehouse, marked SQL, size 2X-Small

3. Run it. Click the Run button, or press Cmd/Ctrl + Enter. “Run all statements” was already ticked in the menu next to the button. The script has 16 statements. The first time they took 22 seconds; the screenshot is from a second run, with 16. The last result is the 30 rows inserted into rentals_raw:

The SQL editor after the run: a check mark and "Just now (16s)" next to the Run button, the result pane shows Results 16 of 16 with num_affected_rows 30 and num_inserted_rows 30

4. Look at it. In the sidebar, open Catalog. There is a new catalog sql_playground with the schema velo and its six tables.

Catalog Explorer: the catalog sql_playground with the schemas default, information_schema, kino and velo; velo is open and lists the tables bikes, customers, price_changes, rentals, rentals_raw and stations

The screenshot already shows a second schema, kino. It comes from a second script, for the exercises at the end of this part.

Every example in the series starts in sql_playground.velo. In a new session, one statement gets you there:

USE sql_playground.velo;

Then check that you have what I have:

SELECT 'bikes' AS table_name, count(*) AS row_count FROM bikes
UNION ALL
SELECT 'customers', count(*) FROM customers
UNION ALL
SELECT 'price_changes', count(*) FROM price_changes
UNION ALL
SELECT 'rentals', count(*) FROM rentals
UNION ALL
SELECT 'rentals_raw', count(*) FROM rentals_raw
UNION ALL
SELECT 'stations', count(*) FROM stations
ORDER BY table_name
table_namestringrow_countbigint
bikes12
customers10
price_changes6
rentals30
rentals_raw30
stations6

How the parts are built

Every part has the same sections:

  1. A question to the bike rental, and a figure: the table before, the result after.
  2. The short version: the syntax in a few lines, with a link to the page in the reference.
  3. Examples, from simple to surprising. One sentence, the SQL, the result.
  4. Traps: what goes wrong, with the example that shows it.
  5. Where it runs: the date and the version I tested on, and the minimum versions the reference gives.
  6. Covered in this part: which pages of the reference the part covers, and what it leaves for other parts.

The SQL is formatted the same way everywhere: keywords in upper case, every clause on its own line.

What this series is not

  • Not a replacement for the reference. Every part links to the pages it covers. The grammar is there.
  • Not a performance course. It’s about what statements do, not about tuning a warehouse.
  • Not about PySpark. Only SQL.

Exercises

Every part of this series ends with a few exercises. They don’t use the bike rental: you have seen those answers in the article. They use a second small dataset, a cinema in Lucerne with five tables: movies, screens, screenings, tickets and members, in the schema sql_playground.kino. It’s as made up as the bike rental, and it has its own odd rows.

A second script creates it: setup/01_kino.sql. Run it the same way as the first one. It has 14 statements, and for me it took 17 seconds.

Entity-relationship diagram of the schema kino in crow's foot notation. Five tables with their columns, types, primary and foreign keys: movies (8 rows), screens (4 rows), screenings (12 rows), tickets (28 rows) and members (8 rows). A movie and a screen each have zero or many screenings, a screening has exactly one movie and one screen. A screening has zero or many tickets, a ticket has exactly one screening and zero or one member

I don’t publish solutions. Every exercise shows what should come out, so you can check yourself.

1.1 Run the script, then count the rows of the five tables. You should get:

table_namestringrow_countbigint
members8
movies8
screenings12
screens4
tickets28

1.2 Look at every table with SELECT *. The cinema has rows that are missing something, wrong or odd, like the bike rental. I put in more than ten. How many do you find? The movies table, to start with:

movie_idinttitlestringgenrestringrelease_yearintruntime_minintlanguagestringage_ratingint
1The Last Tramdrama2024112de12
2Nordlichtdrama202598de12
3Paper Planetsanimation202591en6
4Zero Kelvin Kisscomedy2023104en12
5Les Heures Bleuesdrama2025127fr16
6Alpine Staticthriller2026NULLde16
7paper planets 2animation202688en6
8The Quiet OrchardDrama2022119en12

1.3 The diagram says: a ticket has exactly one screening. That’s the intention, and nothing in the tables enforces it. Is it true for every ticket? Look at tickets and screenings. You should find one ticket that breaks it.

1.4 Take the query from the top of this part and change it: the most recent ticket of every member. If two tickets were sold at the same time, take the one with the higher ticket_id. You should get eight rows, and the first one is not a member:

member_idintticket_idintsold_attimestampprice_chfdecimal
NULL242026-03-06 15:00:00NULL
1182026-03-04 21:00:000.00
2202026-03-05 13:00:0014.00
3262026-03-06 19:00:009.00
4272026-03-07 20:00:0016.00
5232026-03-05 22:10:0020.00
6212026-03-06 09:30:0018.00
8282026-03-06 10:00:0016.00

Where it runs

I ran everything in this part on the Serverless Starter Warehouse of a Databricks Free Edition workspace on 11 November 2026; current_version() reported DBSQL version 2026.44.

The code

The setup script and all statements of this part, with the results as they came back, are in the repository: learn-databricks-sql.

Related Articles

Comments

Comments are powered by giscus. You need a GitHub account to comment.