<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Full Outer Join]]></title><description><![CDATA[I write about open source data &amp; analytics things.]]></description><link>https://fullouterjoin.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 09:38:13 GMT</lastBuildDate><atom:link href="https://fullouterjoin.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Peek(ing) through windows]]></title><description><![CDATA[Setting the stage
When analysing data you'll sooner or later end up with the task to calculate capacity utilization rates based on some raw, murky machine log. Well, at least I have. On multiple occasions. The data usually comes in the form of an eve...]]></description><link>https://fullouterjoin.dev/peeking-through-windows</link><guid isPermaLink="true">https://fullouterjoin.dev/peeking-through-windows</guid><category><![CDATA[dbt]]></category><category><![CDATA[ClickHouse]]></category><category><![CDATA[SQL]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[Qlik]]></category><dc:creator><![CDATA[Jesper Bagge]]></dc:creator><pubDate>Wed, 03 Jul 2024 09:36:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1719999222705/caf27cc7-c721-4a71-b814-9c68a59c7964.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3 id="heading-setting-the-stage">Setting the stage</h3>
<p>When analysing data you'll sooner or later end up with the task to calculate capacity utilization rates based on some raw, murky machine log. Well, at least I have. On multiple occasions. The data usually comes in the form of an event log where each record denotes the time of day, a state that has been reached and typically also which entity that has reached said state. Let's pretend that the states in question are <code>start</code> and <code>stop</code> and now we need to figure out a way to calculate how long an entity has remained in a state until a new state has been reached. Since we only have start times on each record this task is not entirely trivial.</p>
<p>Back in the days when the BI platform <a target="_blank" href="https://www.qlik.com/us/products/qlik-data-analytics">Qlik Sense</a> was the bees knees (and everyone thought that this was a great tool to transform data in - yes this was before we realised we needed observability, data contracts and data unit tests) the solution to this problem was spelled <code>peek()</code>. This was a function for inter-record processing and by sorting the data in some form of descending order we could use <code>peek()</code> to find out stuff from records that had come before. Like the timestamp on the previous record, by which we now could calculate the duration between two timestamps.</p>
<h3 id="heading-enter-the-protagonists">Enter the protagonists</h3>
<p>So how do we solve this using the dynamic superduo <a target="_blank" href="https://www.getdbt.com/">dbt Core</a> and <a target="_blank" href="https://clickhouse.com/">ClickHouse</a>? Well, I thought we could give this a try with <a target="_blank" href="https://clickhouse.com/docs/en/sql-reference/window-functions#standard-window-functions">window functions</a>. Using these tools will give us a democratised and version controlled data transformation, observability through orchestration of our dbt models and we will be doing it at lightspeed thanks to the powerhouse that is spelled ClickHouse. Although the part about observability falls outside the scope of this article, I cannot stress enough the importance of keeping books on how your data warehouse is performing.</p>
<h3 id="heading-add-props">Add props</h3>
<p>Before diving headfirst into data we need to have... well, data. ClickHouse comes with the possibility to create temporary in-memory tables. Neat, right?! So let's start by creating an imaginary log table and fill it with some data. Two machines are reporting when they start and stop running:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">OR</span> <span class="hljs-keyword">REPLACE</span> <span class="hljs-keyword">TABLE</span> machine_log (
    <span class="hljs-string">`machine`</span> <span class="hljs-keyword">String</span>,
    <span class="hljs-string">`start`</span> Datetime32,
    <span class="hljs-string">`state`</span> <span class="hljs-keyword">String</span>
)
<span class="hljs-keyword">ENGINE</span> = <span class="hljs-keyword">Memory</span>;

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span> machine_log <span class="hljs-keyword">Values</span>
    (<span class="hljs-string">'M1'</span>, <span class="hljs-string">'2024-07-01 12:31:44'</span>, <span class="hljs-string">'Start'</span>),
    (<span class="hljs-string">'M1'</span>, <span class="hljs-string">'2024-07-01 18:15:32'</span>, <span class="hljs-string">'Stop'</span>),
    (<span class="hljs-string">'M1'</span>, <span class="hljs-string">'2024-07-02 05:14:22'</span>, <span class="hljs-string">'Start'</span>),
    (<span class="hljs-string">'M1'</span>, <span class="hljs-string">'2024-07-02 10:33:01'</span>, <span class="hljs-string">'Stop'</span>),
    (<span class="hljs-string">'M2'</span>, <span class="hljs-string">'2024-07-01 12:51:04'</span>, <span class="hljs-string">'Start'</span>),
    (<span class="hljs-string">'M2'</span>, <span class="hljs-string">'2024-07-02 03:21:17'</span>, <span class="hljs-string">'Stop'</span>),
    (<span class="hljs-string">'M2'</span>, <span class="hljs-string">'2024-07-02 15:44:15'</span>, <span class="hljs-string">'Start'</span>),
    (<span class="hljs-string">'M2'</span>, <span class="hljs-string">'2024-07-02 22:43:29'</span>, <span class="hljs-string">'Stop'</span>);
</code></pre>
<h3 id="heading-windows-are-for-peeking">Windows are for peeking</h3>
<p>Now that we have data to work with, it's time to let ClickHouse shine. By declaring a WINDOW named <code>above_me</code> along with how to partition, its sort order and window size (or range) we can use some special functions to work with this window. The function <code>lagInFrame()</code> is exactly what we are after. In this context it will do almost exactly what the old <code>peek()</code> function did in Qlik Sense. The difference is that with ClickHouse, the <code>PARTITION</code> bit "resets" the window at the end of the partition, making our work easier since we don't have to keep track of if we've reached data for a new machine. With <code>peek()</code> we had to have extra control-statements for this.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span>
    machine,
    <span class="hljs-keyword">start</span>,
    lagInFrame(<span class="hljs-keyword">start</span>) <span class="hljs-keyword">OVER</span> above_me <span class="hljs-keyword">AS</span> next_event,
    state
<span class="hljs-keyword">FROM</span> machine_log
<span class="hljs-keyword">WINDOW</span> above_me <span class="hljs-keyword">AS</span>(
    <span class="hljs-keyword">PARTITION</span> <span class="hljs-keyword">BY</span> machine
    <span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> machine, <span class="hljs-keyword">start</span> DESCENDING
    <span class="hljs-keyword">ROWS</span> <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">1</span> <span class="hljs-keyword">PRECEDING</span> <span class="hljs-keyword">AND</span> <span class="hljs-keyword">CURRENT</span> <span class="hljs-keyword">ROW</span>
)
</code></pre>
<p>Now that we've placed an additional timestamp - the next one within the same machine code or zero if we're at the end of the window - on each record we can use <code>timestampDiff()</code> to calculate the duration for the current state. Just to make sure that the last state is also calculated correctly we replace the zero with a <code>now()</code>-value since we assume that the machine has been in this state ever since. We also subtract a second from the <code>next_event</code> value to not have overlapping intervals.</p>
<p>The recommended way to write transformations in dbt is by typing it out as a <a target="_blank" href="https://docs.getdbt.com/terms/cte">CTE</a>. The reason for this is that since SQL is a language that many other humans will read, CTE is much easier to parse compared to arbitrarily nested SELECT-statements. For humans, at least...</p>
<pre><code class="lang-sql"><span class="hljs-keyword">WITH</span>
    <span class="hljs-keyword">raw</span> <span class="hljs-keyword">AS</span> (

        <span class="hljs-keyword">SELECT</span>
            machine,
            <span class="hljs-keyword">start</span>,
            lagInFrame(<span class="hljs-keyword">start</span>) <span class="hljs-keyword">OVER</span> above_me <span class="hljs-keyword">AS</span> next_event,
            state
        <span class="hljs-keyword">FROM</span> machine_log
        <span class="hljs-keyword">WINDOW</span> above_me <span class="hljs-keyword">AS</span>(
            <span class="hljs-keyword">PARTITION</span> <span class="hljs-keyword">BY</span> machine
            <span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> machine, <span class="hljs-keyword">start</span> DESCENDING
            <span class="hljs-keyword">ROWS</span> <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">1</span> <span class="hljs-keyword">PRECEDING</span> <span class="hljs-keyword">AND</span> <span class="hljs-keyword">CURRENT</span> <span class="hljs-keyword">ROW</span>
        )

    ),
    <span class="hljs-keyword">duration</span> <span class="hljs-keyword">AS</span> (

        <span class="hljs-keyword">SELECT</span>
            machine,
            <span class="hljs-keyword">start</span>,
            <span class="hljs-keyword">if</span>(
                next_event = <span class="hljs-number">0</span>, <span class="hljs-keyword">now</span>(),
                next_event - <span class="hljs-number">1</span>
            ) <span class="hljs-keyword">AS</span> <span class="hljs-keyword">end</span>,
            <span class="hljs-keyword">timestampDiff</span>(<span class="hljs-keyword">MINUTE</span>, <span class="hljs-keyword">start</span>, <span class="hljs-keyword">end</span>) <span class="hljs-keyword">as</span> duration_minutes,
            state
        <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">raw</span>

    )

<span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">duration</span>;
</code></pre>
<h3 id="heading-and-the-results-are-in">And the results are in!</h3>
<p>Now that we have a duration for each state and for each machine the analytics part is a nice stroll in the park. This data could easily be visualised in any modern BI tool. My personal favourite is <a target="_blank" href="https://superset.apache.org/">Apache Superset</a> because it sits so nicely on top of ClickHouse. If you're a cloud person dbt, ClickHouse and <a target="_blank" href="https://preset.io/">Preset</a> offer hosted versions. If you just want to quickly summarize the durations and move on to the next task then this aggregation finisher might be just for you. Just replace the final <code>SELECT</code>-clause in the model above:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span>
    machine,
    state,
    <span class="hljs-keyword">sum</span>(duration_minutes) <span class="hljs-keyword">as</span> total_running_minutes
<span class="hljs-keyword">FROM</span> <span class="hljs-keyword">duration</span>
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> machine, state
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> machine, state;
</code></pre>
<p>Hands up if you didn't know data engineering could be this fun!</p>
]]></content:encoded></item><item><title><![CDATA[Automagic schema inference killed the data engineer]]></title><description><![CDATA[Todays lesson in working with legacy output is: data contracts, data contracts and data contracts.
The great thing about shipping structured data as text files is it is so darn easy! Just store it as a CSV and boom: minimum overhead and just the righ...]]></description><link>https://fullouterjoin.dev/automagic-schema-inference-killed-the-data-engineer</link><guid isPermaLink="true">https://fullouterjoin.dev/automagic-schema-inference-killed-the-data-engineer</guid><category><![CDATA[ClickHouse]]></category><category><![CDATA[dbt]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Jesper Bagge]]></dc:creator><pubDate>Tue, 19 Mar 2024 10:39:26 GMT</pubDate><content:encoded><![CDATA[<p>Todays lesson in working with legacy output is: data contracts, data contracts and data contracts.</p>
<p>The great thing about shipping structured data as text files is it is so darn easy! Just store it as a CSV and boom: minimum overhead and just the right amount of structure to not risking to be called out a liar when saying that the data is structured.</p>
<p>But it does not come entirely without risk. There's zero context to this data so you're leaving the receiving end with a lot of guesswork. Does this column contain text? Integers or floats? Are there alphanumeric keys in this column aptly named 'id'? Perhaps I should parse everything as text just to be safe?</p>
<p>I spent a hefty portion of two working days, struggling to parse tab separated data from MySQL dumps. There were errors. ClickHouse couldn't parse these files because it expected tabs where there apparently was none. However, I could easily spot the tabs in the raw files and without difficulty read the same data into a pandas DataFrame. I even did a manual count just to make sure that the data wasn't dirty.</p>
<p>And then I spotted it! Not in the files, no. And not in the error message, no absolutely not! It was over on <a target="_blank" href="https://clickhouse.com/docs/en/interfaces/formats#tabseparated">ClickHouses excellent documentation pages</a>. There I found the setting:</p>
<p><code>input_format_tsv_use_best_effort_in_schema_inference=1</code></p>
<p>It might be fun, at first. Cool, some might say. Leaving the kitchen door wide open to ghouls, I would argue. In my case, a column that had its schema inferred as <code>Int64</code> suddenly contained alphanumeric caracters.</p>
<p>The lesson I take with me from this experience is to keep telling the parser <em>exactly</em> what data types to expect. You configure this as a long string after telling the enginge what format to parse. Yes, the parameters for the s3 table engine will be a short novel, but at least the explicitly typed out columns and data types serves as a minimum data contract. Those lines of code in dbt, version controlled in all its glory will be the 1:st line of contracts, describing what type of data is expected in that pipeline.</p>
]]></content:encoded></item><item><title><![CDATA[Pivoting reports into tables]]></title><description><![CDATA[A very long time ago my mentor at the time took a peek at a spreadsheet full of data I was tasked to read into QlikView. "That data is in a reporting format.", he said hinting at the fact that there was one column for each year of metrics. "You will ...]]></description><link>https://fullouterjoin.dev/pivoting-reports-into-tables</link><guid isPermaLink="true">https://fullouterjoin.dev/pivoting-reports-into-tables</guid><category><![CDATA[dbt]]></category><category><![CDATA[ClickHouse]]></category><dc:creator><![CDATA[Jesper Bagge]]></dc:creator><pubDate>Thu, 29 Jun 2023 08:50:34 GMT</pubDate><content:encoded><![CDATA[<p>A very long time ago my mentor at the time took a peek at a spreadsheet full of data I was tasked to read into QlikView. "That data is in a reporting format.", he said hinting at the fact that there was one column for each year of metrics. "You will have to transpose those columns into rows so you get the year in one column and the metric in another, like transactions."</p>
<p>In QlikView this was a fairly common practice at the time since many data sources came in spreadsheet format. There was a function named CrossTable that took care of that effortlessly.</p>
<p>In ClickHouse the same can be achieved by grouping the year columns into an array and then doing an ARRAY JOIN which will produce a new table by iterating through the array creating a separate record for each item.</p>
<p>Let's pretend that we have a copy of <a target="_blank" href="https://en.wikipedia.org/wiki/The_Economist_Democracy_Index#List_by_country">The Economist's Democracy Index list by country</a> ingested into ClickHouse. It would look something like this when queried:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Region</td><td>Country</td><td>Regime type</td><td>2022</td><td>2021</td><td>2020</td></tr>
</thead>
<tbody>
<tr>
<td>North America</td><td>Canada</td><td>Full democracy</td><td>8.88</td><td>8.87</td><td>9.24</td></tr>
<tr>
<td>North America</td><td>United States</td><td>Flawed democracy</td><td>7.85</td><td>7.85</td><td>7.92</td></tr>
<tr>
<td>Western Europe</td><td>Austria</td><td>Full democracy</td><td>8.2</td><td>8.07</td><td>8.16</td></tr>
</tbody>
</table>
</div><p>To get this table into a transactional state where we can perform aggregation functions like Min(), Max() and Avg() we can create a <a target="_blank" href="https://docs.getdbt.com/docs/build/models">dbt model</a> like this:</p>
<pre><code class="lang-sql">{{
    config(
        materialized='table', 
        engine='MergeTree()', 
        order_by='(country, year)'
    )
}}

<span class="hljs-keyword">WITH</span> pivoted <span class="hljs-keyword">AS</span> (

    <span class="hljs-keyword">SELECT</span>
        Region <span class="hljs-keyword">AS</span> region,
        Country <span class="hljs-keyword">AS</span> country,
        <span class="hljs-string">`Regime type`</span> <span class="hljs-keyword">AS</span> regime_type,
        <span class="hljs-keyword">year</span>,
        democracy_index
    <span class="hljs-keyword">FROM</span> ingest.democracy_index_by_country_2022
    <span class="hljs-keyword">LEFT</span> <span class="hljs-built_in">ARRAY</span> <span class="hljs-keyword">JOIN</span>
        splitByString(<span class="hljs-string">','</span>, <span class="hljs-string">'2022,2021,2020'</span>) <span class="hljs-keyword">AS</span> <span class="hljs-keyword">year</span>,
        [<span class="hljs-string">`2022`</span>,<span class="hljs-string">`2021`</span>,<span class="hljs-string">`2020`</span>] <span class="hljs-keyword">AS</span> democracy_index

), <span class="hljs-keyword">final</span> <span class="hljs-keyword">AS</span> (

    <span class="hljs-keyword">SELECT</span>
        toLowCardinality(region) <span class="hljs-keyword">AS</span> region,
        toLowCardinality(assumeNotNull(country)) <span class="hljs-keyword">AS</span> country,
        toLowCardinality(regime_type) <span class="hljs-keyword">AS</span> regime_type,
        toUInt16(<span class="hljs-keyword">year</span>) <span class="hljs-keyword">AS</span> <span class="hljs-keyword">year</span>,
        <span class="hljs-keyword">round</span>(democracy_index, <span class="hljs-number">2</span>) <span class="hljs-keyword">as</span> democracy_index
    <span class="hljs-keyword">FROM</span> pivoted

)
<span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">final</span>
</code></pre>
<p>There is a lot of stuff going on in here! The double curly brackets at the top tell dbt that I want the result of this SQL query to be materialized as a new table in the database. Where and how this table is created is defined by how we've set up our dbt project.</p>
<p>I always recommend typing out SQL in a CTE (Common Table Expression) format. It is so much easier to read by both myself a week from now and by my colleagues.</p>
<p>The real magic happens in the LEFT ARRAY JOIN clause. The first statement after is a trick to create an array of year names by splitting a string into individual values by the splitByString() function. In the second statement, we treat all our year columns (escaped by backtick since column names are only numbers) as an array. When LEFT ARRAY JOIN is executed the year and democracy_index columns are created by iterating through the two arrays.</p>
<p>The final bit is some best practices to get the data in a healthy shape before running analytic queries. Since sorting columns shouldn't contain NULL and my table will be sorted by country and year we apply assumeNotNull() to the country column.</p>
]]></content:encoded></item><item><title><![CDATA[Let your table have the same structure as your JSON]]></title><description><![CDATA[I love dbt Core and how sources enable me to test the freshness of hopefully recently ingested data by running
dbt source freshness

to test that my ingestion pipelines are delivering. However, when running dbt Core the output is just a JSON file pla...]]></description><link>https://fullouterjoin.dev/let-your-table-have-the-same-structure-as-your-json</link><guid isPermaLink="true">https://fullouterjoin.dev/let-your-table-have-the-same-structure-as-your-json</guid><category><![CDATA[ClickHouse]]></category><category><![CDATA[dbt]]></category><dc:creator><![CDATA[Jesper Bagge]]></dc:creator><pubDate>Tue, 27 Jun 2023 19:17:30 GMT</pubDate><content:encoded><![CDATA[<p>I love dbt Core and how <a target="_blank" href="https://docs.getdbt.com/docs/build/sources">sources</a> enable me to test the freshness of hopefully recently ingested data by running</p>
<pre><code class="lang-bash">dbt <span class="hljs-built_in">source</span> freshness
</code></pre>
<p>to test that my ingestion pipelines are delivering. However, when running dbt Core the output is just a JSON file placed in the <code>target</code> folder at the root level of your project. Since I’m running my dbt jobs with GitHub Actions that file is lost as soon as the job is finished.</p>
<p>Unless I HTTP-post the JSON data to ClickHouse, that is…</p>
<p>So I’ll start by creating a table that will hold parts of the sources.json data that I want to analyze and take action on as they happen. The JSON file has a structure looking something like this:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"metadata"</span>: {
    <span class="hljs-attr">"dbt_schema_version"</span>: <span class="hljs-string">"https://schemas.getdbt.com/dbt/sources/v3.json"</span>,
    <span class="hljs-attr">"dbt_version"</span>: <span class="hljs-string">"1.4.6"</span>,
    <span class="hljs-attr">"generated_at"</span>: <span class="hljs-string">"2023-06-27T08:31:30.583005Z"</span>,
    ....
  },
  <span class="hljs-attr">"results"</span>: [
    {
      <span class="hljs-attr">"unique_id"</span>: <span class="hljs-string">"arbitrary.model.name"</span>,
      <span class="hljs-attr">"max_loaded_at"</span>: <span class="hljs-string">"2023-03-31T11:36:29.701000+00:00"</span>,
      <span class="hljs-attr">"max_loaded_at_time_ago_in_s"</span>: <span class="hljs-number">7599300.299</span>,
      <span class="hljs-attr">"status"</span>: <span class="hljs-string">"error"</span>,
      ...
    },
    ...
  ],
  ...
}
</code></pre>
<p>I’d like to recreate this structure in a table in ClickHouse so that I can do an HTTP-post using cURL with the contents from sources.json in my payload, with zero transformations. I will create a column for <code>metadata</code> that will be a named tuple and a column for <code>result</code> that will be an array of named tuples. And finish off with a timestamp for ingestion time. But first I need to shut off a ClickHouse-feature that flattens tuples. The docs only mention this for the Nested data type but this is also necessary for the Tuple data type.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SET</span> flatten_nested=<span class="hljs-number">0</span>; 

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> ingest.dbt_source_freshness (
     metadata Tuple(dbt_schema_version <span class="hljs-keyword">String</span>, dbt_version <span class="hljs-keyword">String</span>, generated_at <span class="hljs-keyword">String</span>),
     results <span class="hljs-built_in">Array</span>(Tuple(unique_id <span class="hljs-keyword">String</span>, <span class="hljs-keyword">status</span> <span class="hljs-keyword">String</span>, max_loaded_at_time_ago_in_s Float64)),
     inserted_utc DateTime64(<span class="hljs-number">3</span>) <span class="hljs-keyword">DEFAULT</span> now64(<span class="hljs-number">3</span>, <span class="hljs-string">'UTC'</span>)
)
<span class="hljs-keyword">ENGINE</span> = MergeTree()
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> (inserted_utc,)
<span class="hljs-keyword">PARTITION</span> <span class="hljs-keyword">BY</span> toYYYYMM(inserted_utc);
</code></pre>
<p>Now I’m able to post the results of my dbt freshness test directly into this table by a cURL call similar to this</p>
<pre><code class="lang-bash">curl -X POST -H <span class="hljs-string">"Content-Type: application/json"</span> -H <span class="hljs-string">"X-ClickHouse-User: username"</span> -H <span class="hljs-string">"X-ClickHouse-Key: password"</span> -d @./target/sources.json https://my.clickhouse.server:8443/?query=INSERT%20INTO%20ingest.dbt_source_freshness%20FORMAT%20JSONEachRow
</code></pre>
]]></content:encoded></item></channel></rss>