Skip to content
Keboola Docs

Transform data

Create an SQL transformation that joins a summer of sales, rosters and weather with a live forecast into one table per café and day, and learn how mappings keep Storage safe.

Tutorial Getting Started

Sales know what sold, the weather what kind of day it was, the roster who was on shift, the forecast what is coming, and none of them knows what the others know.

Before you start

Where you are six raw tables in Storage, nothing joined yet.

When you finish one table that answers the question, and a transformation you can re-run.

You need

What each tab needs

  • Prompt tab Kai, the Kai Agent button in the top bar, on supported stacks. A new Free Plan project has it from the start; elsewhere an organization admin turns it on once: Get started with Kai.
  • UI tab a browser.
  • CLI / API tab kbagent, connected to your project with write access.

Prompt, UI and CLI / API: three ways to do the same task, not three steps. Pick one tab below; later pages open on it.

In the chat, Kai asks before each change it makes (how approvals work), and most of it can be undone: if a step builds something else, say so in the same chat.

  1. Tables in Storage
  2. Input mapping copies them in, under the names your SQL uses
  3. Your SQL runs in a temporary schema
  4. Output mapping writes back only the tables you name

Screenshot - A new BigQuery transformation, Boolabean staffing outlook: empty Table Input Mapping and Table Output Mapping sections with their add buttons, above an empty Queries section offering Create Multiple Queries

  • Mappings decide what goes in and out. Input mapping copies in what you list (Kai’s builds read it in place); output mapping writes back only what you name, so nothing else in Storage changes and Keboola can track lineage.
  • Table names matter, bucket names do not. The SQL reads sales, weather_daily, staffing, stores and forecast (not products); keep those names in the input mapping, or edit the queries to match. Buckets carry configuration IDs, so yours will differ.
  • Find out which SQL your project speaks: Transformations → Create Transformation lists Snowflake SQL Transformation or Google BigQuery Transformation. New Free Plan projects default to the BigQuery backend; contract customers choose theirs.
  1. Daily demand: units per café per day, next to that day's weather
  2. Weather bands: each café's average demand per kind of day
  3. Roster: one headcount per café per weekday
  4. The outlook: each forecast day's average divided by its roster, flagged over 67 per person
The bands, and why they are half-open

Every summer day goes into a temperature band (under 18 °C, 18 to under 23, 23 to under 28, 28 and over) and a rain band (dry under 0.5 mm, wet otherwise). The average demand per café per band is what a day like that has produced before. The chain rosters by weekday, so the roster is a single value for every café and weekday.

The bands are half-open: < 18, then < 23, then < 28, then everything else, and a day at exactly 23.0 °C belongs to exactly one of them. The same CASE appears twice in the query, once for the summer and once for the forecast, and the two have to agree to the character: a forecast day sorted into a band the history side never produced finds no average and comes out empty. Nine summer days sit exactly on 18, 23 or 28 °C and twelve exactly on 0.5 mm, so where the boundary falls is not academic.

Writing SQL transformations is Kai’s home ground (SQL Transformations), and it already has your table schemas in front of it. Open Kai Agent in the top bar and ask:

Prompt
Build an SQL transformation called "Boolabean staffing outlook" with one output table, staffing_outlook. Storage columns arrive as text, so cast before you compare or average.
1. Daily demand: from sales, sum units per store per date, and join weather_daily on store_id and date.
2. Weather bands: temperature under 18, 18 to under 23, 23 to under 28, and 28 and over, each split into dry (rain under 0.5 mm) and wet (0.5 mm or more). Use half-open ranges so no day lands in two bands. Average the daily units per store per band.
3. Roster: from staffing, the rostered headcount per store per weekday. It is the same every week, so this is one value per store and weekday.
4. Forecast: the forecast table's location_id 0 to 5 maps to store_id S01 to S06 in that order, the order the coordinates appear in the request URL.
5. Output one row per store per forecast day with exactly these columns: store_name, city, date, weekday, forecast_temp_c, forecast_rain_mm, expected_units (the store's historical average in the matching band), rostered_staff, expected_units_per_person, and short_handed, true when that figure exceeds 67.
Run it, then tell me how many rows staffing_outlook has and which store and day come out most stretched.

Kai writes it in your project’s own SQL dialect; on our Free Plan project that was BigQuery. Keep the column list in point 5 as written: the later pages type those names into their prompts and checks. If the forecast week has cold, dry days (under 18 °C, no rain) that the summer’s sales never had, Kai may first ask what to do with them; Leave them blank is what the rest of the guide expects.

Expect two approvals: one for the transformation and one for the run. Kai builds it on read-only input: it reads the source tables by their full Storage names and leaves the input mapping empty, so you will not see one in its configuration.

Why the prompt is this long

Spelling out the five parts is the point. Ask only for “a staffing table” and you get a different set of columns, and the check below will not match. The half-open sentence matters most, and the cast sentence second: Kai samples the tables first to see the date format and the text values, and then casts everything it compares.

The mapping sections of the UI tab describe the hand-built path; it gives the same rows and figures, though Kai picks its own column types and writes the weekday out in full.

The chat links what Kai made, so that is where to find it afterwards: the transformation, and the job that ran it.

Screenshot - The Kai chat after the transformation prompt: Transformation created, Boolabean staffing outlook, Google BigQuery; Job succeeded; then Kai's answer, 42 rows with Boolabean Zelný trh in Brno the most stretched on two days, three short-handed rows in all, and a closing line that it will confirm once the job has written the table

Check: Kai reports staffing_outlook with 42 rows and names the most stretched café and day. It may answer from a test run of its query while the job is still running, as in the screenshot; the job’s card in the chat says Job succeeded once the table is written. If the count differs, the joins differ: re-prompt, or fix the code in place. Later steps need only that table, at out.c-Boolabean-staffing-outlook.staffing_outlook where Kai puts it, so a close-enough table is fine; if yours landed in another bucket, use its ID in their prompts.

To see what the SQL does, ask in the same chat:

Follow-up prompt
Explain the SQL of the "Boolabean staffing outlook" transformation in plain words.

It only reads, so it asks for no approval by default.

Coming from the Prompt or CLI / API tab? The run already happened: the prompt asked for it, and job run --wait did it. Skip to the checks below.

Click Run Transformation and confirm with Run. The job copies in what the input mapping lists, runs your SQL and writes staffing_outlook back to Storage. It takes about a minute, and a green Success means it worked; the notification’s Show job link and Jobs both lead to it. The job below is Kai’s build, which reads its tables in place, so its Mapping says Input: No tables.

Screenshot - The Run transformation confirmation dialog with an unticked power user checkbox above the green Run button

Screenshot - The job page for Boolabean staffing outlook with a green Success badge, a 55 sec duration, and a Mapping card showing Input: No tables and Output staffing_outlook

Then open Storage: a new bucket out.c-Boolabean-staffing-outlook (in lower case if the UI tab created it), listed without its out.c- prefix and with an OUT badge, holds staffing_outlook with 42 rows and 10 columns.

Screenshot - The Storage table list with staffing_outlook and its Recently Updated By column naming the transformation

  • 42 rows, six cafés times seven forecast days. More means an input has duplicate rows; fewer means a join dropped a café or a day, most often because location_id did not map onto a store_id.
  • No two rows share a café and a date. Sort the Data Sample tab by store_name: each café shows seven different dates.
  • short_handed is true on a few rows in a warm week. The sample’s history is one summer, so a week under 18 °C flags nothing, and cold, dry days leave most cafés without an expected_units (see below). The top row by expected_units_per_person is the café and day the next page asks about.
The ten columns, upper-case names, and empty cells

The 10 columns are store_name, city, date, weekday, forecast_temp_c, forecast_rain_mm, expected_units, rostered_staff, expected_units_per_person and short_handed. If Kai built the table, Storage may show them in upper case: Snowflake upper-cases an alias that is not quoted, and the names are the same names.

An empty expected_units on a row is not an error: it means the summer never had a day in that café’s band, so there is no history to average. In this sample that band is cold and dry: five of the six cafés had no dry day under 18 °C all summer. And on a day under 18 °C no café’s history comes to more than 65.5 units a person, below the 67 that flags a day, so a cold week has no short-handed rows at all.

How many rows are flagged depends on the forecast; the week in our screenshots had three.

The table list’s Recently Updated By column names the transformation and its backend (Boolabean staffing outlook over Google BigQuery in the screenshot above): the fastest way to answer “where did this table come from?” months later. Running the transformation again rebuilds the table, so it is safe to re-run while you experiment, and next week’s forecast gives you next week’s table, which is what the scheduling page is for.

Object ‘SALES’ does not exist (Snowflake)

A table is missing from the input mapping, or the Table name inside the transformation differs from what the SQL uses. Snowflake upper-cases unquoted identifiers, which is why every identifier is double-quoted. On BigQuery the equivalent error is Table ... was not found.

The job succeeds, but Storage has no new table

The output mapping is empty or names a table your SQL never creates. The names must match exactly: staffing_outlook.

Failed to process output mapping … Some columns are missing in the csv file

The full message ends with Missing columns: STORE_NAME, CITY, …. The table already exists in Storage, with column names in a different case from the ones your query produced this time. An existing table remembers the exact names of its columns, and Snowflake upper-cases an alias you did not quote, so switching between quoted and unquoted aliases between runs is enough to trigger it. Either keep the alias style that created the table, or delete the table in Storage and run again. It happens when the Prompt tab created the table first and the hand-written query writes into it, and the other way round.

Numeric value ” is not recognized

Tables loaded from CSV arrive as text columns unless you give them types, so an empty cell is '' rather than NULL. The queries cast with TRY_TO_ (Snowflake) or SAFE_CAST (BigQuery) before comparing; if you see this error, a cast got dropped somewhere.

The table has more than 42 rows

An input has duplicates. The forecast configuration ran with Incremental load on and appended a second copy, or stores lists a café twice. The joins cannot do this on their own; look at the inputs, and switch incremental load off on the forecast row.

Every row has an empty expected_units

The band CASE on the forecast side does not produce the same labels as the one on the history side, so nothing joins. Compare the two expressions character by character.

Invalid columns: _timestamp when the output is written

Your input tables were staged by cloning, which copies Keboola’s internal _timestamp column along with the data, and that column cannot be written back to Storage. It is not backend-specific and it is not a problem with the query. The clean fix is the dropTimestampColumn option on the input mapping; see the _timestamp system column.

You want to see what the query returns before saving

That is what a workspace is for.

  • Use a workspace to develop and test queries against a copy of the data before committing them to a transformation. This is how the work is really done.
Ask Kai

Hi, I'm Kai — Keboola's AI assistant for the docs. Ask me anything and I'll answer from the documentation and cite the pages I use.

Kai is an AI and can make mistakes. Check the sources it links.