<?xml version="1.0" encoding="UTF-8" ?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" version="2.0"><channel><title>CrunchyData Blog</title>
<atom:link href="https://www.crunchydata.com/blog/topic/analytics/rss.xml" rel="self" type="application/rss+xml" />
<link>https://www.crunchydata.com/blog/topic/analytics</link>
<image><url>https://www.crunchydata.com/card.png</url>
<title>CrunchyData Blog</title>
<link>https://www.crunchydata.com/blog/topic/analytics</link>
<width>800</width>
<height>419</height></image>
<description>PostgreSQL experts from Crunchy Data share advice, performance tips, and guides on successfully running PostgreSQL and Kubernetes solutions</description>
<language>en-us</language>
<pubDate>Wed, 21 May 2025 10:00:00 EDT</pubDate>
<dc:date>2025-05-21T14:00:00.000Z</dc:date>
<dc:language>en-us</dc:language>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
<item><title><![CDATA[ Archive Postgres Partitions to Iceberg ]]></title>
<link>https://www.crunchydata.com/blog/archive-postgres-partitions-to-iceberg</link>
<description><![CDATA[ Create a clean and simple archive process from Postgres to Iceberg with partitioning and automatic replication. ]]></description>
<content:encoded><![CDATA[ <p>Postgres comes with <a href=https://www.crunchydata.com/blog/native-partitioning-with-postgres>built-in partitioning</a> and you can also layer in for <code>pg_partman</code> for additional help with maintenance of your partitioning. It works quite well for partitioning your data to make it easy to retain a limited set of data and improve performance if your primary workload is querying a small time series focused subset of data. Oftentimes, when implementing partitioning you only keep a portion of your data then drop older data as it ages out for cost management.<p>But what if we could move old partitions seamlessly to Iceberg that could retain all our data forever, while only maintaining recent partitions within Postgres? Could we have a perfect world of full long term copy in Iceberg easily query-able from a warehouse, but Postgres still functioning as the operational database with the most recent 30 days of data?<p>With the <a href=https://www.crunchydata.com/blog/logical-replication-from-postgres-to-iceberg>latest replication support</a> for Crunchy Data Warehouse this works seamlessly, lets dig in.<h2 id=first-lets-setup-our-partitioning><a href=#first-lets-setup-our-partitioning>First lets setup our partitioning</a></h2><p>If you’d like to follow along at home, here’s some code to set up a sample set of partitioned data resembling a web analytics data set.<pre><code class=language-sql>CREATE TABLE page_hits (
    id SERIAL,
    site_id INT NOT NULL,
    ingest_time TIMESTAMPTZ NOT NULL,
    url TEXT NOT NULL,
    request_country TEXT,
    ip_address INET,
    status_code INT,
    response_time_msec INT,
    PRIMARY KEY (id, ingest_time)
) PARTITION BY RANGE (ingest_time);
</code></pre><p>This function will create a set of partitions for us for the last 30 days.<pre><code class=language-sql>DO $$
DECLARE
  d DATE;
BEGIN
  FOR d IN SELECT generate_series(DATE '2025-04-20', DATE '2025-05-19', INTERVAL '1 day') LOOP
    EXECUTE format($f$
      CREATE TABLE IF NOT EXISTS page_hits_%s PARTITION OF page_hits
      FOR VALUES FROM ('%s') TO ('%s');
    $f$, to_char(d, 'YYYY_MM_DD'), d, d + INTERVAL '1 day');
  END LOOP;
END $$;
</code></pre><p>Your database should look something like this:<pre><code class=language-sql>                            List of relations
 Schema |          Name           |       Type        |       Owner
--------+-------------------------+-------------------+-------------------
 public | page_hits               | partitioned table | postgres
 public | page_hits_2025_04_20    | table             | postgres
 public | page_hits_2025_04_21    | table             | postgres
...
 public | page_hits_2025_05_18    | table             | postgres
 public | page_hits_2025_05_19    | table             | postgres
 public | page_hits_id_seq        | sequence          | postgres

</code></pre><p>Now we can generate some sample data. In this case we’re going to generate 1000 rows per day for each of our tables:<pre><code class=language-sql>DO $$
DECLARE
  d DATE;
BEGIN
  FOR d IN
    SELECT generate_series(DATE '2025-04-20', DATE '2025-05-19', '1 day'::INTERVAL)
  LOOP
    INSERT INTO page_hits (site_id, ingest_time, url, request_country, ip_address, status_code, response_time_msec)
    SELECT
        (RANDOM() * 30)::INT,
        d + (i || ' seconds')::INTERVAL,
        'http://example.com/' || substr(md5(random()::text), 1, 12),
        (ARRAY['China', 'India', 'Indonesia', 'USA', 'Brazil'])[1 + (random() * 4)::INT],
        inet '10.0.0.0' + (random() * 1000000)::INT,
        (ARRAY[200, 200, 200, 404, 500])[1 + (random() * 4)::INT],
        (random() * 300)::INT
    FROM generate_series(1, 1000) AS s(i);
  END LOOP;
END $$;

</code></pre><p>Now that we have some data within our Postgres setup lets connect things to our Crunchy Data Warehouse and get them replicated over.<h2 id=set-up-replication-to-iceberg><a href=#set-up-replication-to-iceberg>Set up replication to Iceberg</a></h2><p>Within the setup you want to specify to publish via the root partition - <code>root=true</code>. This keeps partitions in Postgres but does not partition Iceberg since it has its own organization of data files.<pre><code class=language-sql>CREATE PUBLICATION hits_to_iceberg
FOR TABLE page_hits
WITH (publish_via_partition_root = true);
</code></pre><p>Set up the replications users<pre><code class=language-sql>-- create a new user
CREATE USER replication_user WITH REPLICATION PASSWORD '****';

-- grant appropriate permissions
GRANT SELECT ON ALL TABLES IN SCHEMA public TO replication_user;
</code></pre><p>And on the warehouse end, subscribe to the originating data. Since we’ve specified the create_tables_using Iceberg, this data will be stored in Iceberg.<pre><code class=language-sql>CREATE SUBSCRIPTION http_to_iceberg
CONNECTION 'postgres://replication_user:****@p.qzyqhjdg3fhejocnta3zvleomq.db.postgresbridge.com:5432/postgres?sslmode=require'
PUBLICATION hits_to_iceberg
WITH (create_tables_using = 'iceberg', streaming, binary, failover);
</code></pre><p>And here’s the Iceberg table.<pre><code class=language-sql>                          List of relations
 Schema |          Name           |     Type      |       Owner
--------+-------------------------+---------------+-------------------
 public | page_hits               | foreign table | postgres

</code></pre><h2 id=now-query-data-stored-in-iceberg-from-postgres><a href=#now-query-data-stored-in-iceberg-from-postgres>Now query data stored in Iceberg from Postgres</a></h2><p>Here we can see the daily traffic insights for each country, breaking down the number of hits, success rate, average response time, and top error codes:<pre><code class=language-sql>SELECT
  date_trunc('day', ingest_time) AS day,
  request_country,
  COUNT(*) AS total_hits,
  ROUND(100.0 * SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END) / COUNT(*), 2) AS success_rate_percent,
  ROUND(AVG(response_time_msec), 2) AS avg_response_time_msec,
  MODE() WITHIN GROUP (ORDER BY status_code) AS most_common_status
FROM
  page_hits
GROUP BY
  day, request_country
ORDER BY
  day, request_countr
</code></pre><pre><code class=language-sql>          day           | request_country | total_hits | success_rate_percent | avg_response_time_msec | most_common_status
------------------------+-----------------+------------+----------------------+------------------------+--------------------
 2025-04-20 00:00:00+00 | Brazil          |        128 |                68.75 |                 146.83 |                200
 2025-04-20 00:00:00+00 | China           |        138 |                65.94 |                 145.67 |                200
 2025-04-20 00:00:00+00 | India           |        245 |    64.90000000000001 |                  153.8 |                200
 2025-04-20 00:00:00+00 | Indonesia       |        230 |    64.34999999999999 |                 151.43 |                200

</code></pre><h2 id=now-drop-the-older-postgres-partition><a href=#now-drop-the-older-postgres-partition>Now drop the older Postgres partition</a></h2><p>Since data is replicated and a copy is in Iceberg, we can drop partitions at a specific time to free up storage and memory on our main operational Postgres database.<pre><code class=language-sql>--drop partition
DROP TABLE page_hits_2025_04_20;
</code></pre><pre><code class=language-sql>-- show missing partition in the table list
                            List of relations
 Schema |          Name           |       Type        |       Owner
--------+-------------------------+-------------------+-------------------
 public | page_hits               | partitioned table | postgres
 public | page_hits_2025_04_21    | table             | postgres
 public | page_hits_2025_04_22    | table             | postgres

</code></pre><pre><code class=language-sql>-- query iceberg, data is still there
          day           | request_country | total_hits | success_rate_percent | avg_response_time_msec | most_common_status
------------------------+-----------------+------------+----------------------+------------------------+--------------------
 2025-04-20 00:00:00+00 | Brazil          |        128 |                68.75 |                 146.83 |                200
 2025-04-20 00:00:00+00 | China           |        138 |                65.94 |                 145.67 |                200
 2025-04-20 00:00:00+00 | India           |        245 |    64.90000000000001 |                  153.8 |                200
 2025-04-20 00:00:00+00 | Indonesia       |        230 |    64.34999999999999 |                 151.43 |                200

</code></pre><h2 id=summary><a href=#summary>Summary</a></h2><p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/bb816cf6-4b05-4430-324a-9eb7c7623000/public><p>Here’s the recipe for simple Postgres archiving with long term cost effective data retention:<p>1 - Partition your high throughput data - this is ideal for performance and management anyways.<p>2 - Replicate your data to Iceberg for easy reporting and long term archiving.<p>3 - Drop partitions at the ideal interval.<p>4 - Continue to query archived data from Postgres. ]]></content:encoded>
<category><![CDATA[ Partitioning ]]></category>
<category><![CDATA[ Analytics ]]></category>
<author><![CDATA[ Craig.Kerstiens@crunchydata.com (Craig Kerstiens) ]]></author>
<dc:creator><![CDATA[ Craig Kerstiens ]]></dc:creator>
<guid isPermalink="false">4596ecd47c785293f3b49cc133441a30a91280d7f163aea74c079786938c604c</guid>
<pubDate>Wed, 21 May 2025 10:00:00 EDT</pubDate>
<dc:date>2025-05-21T14:00:00.000Z</dc:date>
<atom:updated>2025-05-21T14:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Announcing pg_parquet v.0.4.0: Google Cloud Storage, https storage, and more ]]></title>
<link>https://www.crunchydata.com/blog/announcing-pg_parquet-v-0-4-google-cloud-storage-https-storage-and-more</link>
<description><![CDATA[ pg_parquet is a copy/to from for Postgres and Parquet. We're excited to announce integration with Google Cloud storage, https, and additional formats. ]]></description>
<content:encoded><![CDATA[ <p>What began as a hobby Rust project to explore the PostgreSQL extension ecosystem and the Parquet file format has grown into a handy component for folks integrating Postgres and Parquet into their data architecture. Today, we’re excited to release version 0.4 of <a href=https://github.com/CrunchyData/pg_parquet>pg_parquet</a>.<p>This release includes:<ul><li>COPY TO/FROM Google Cloud Storage<li>COPY TO/FROM http(s) stores<li>COPY TO/FROM stdin/stdout with (FORMAT PARQUET)<li>Support Parquet UUID, JSON, JSONB types</ul><p>If you're unfamiliar with pg_parquet, pg_parquet makes it easy to export and import Parquet files directly within Postgres, without relying on third-party tools. It's not a query engine but a migration tool. When working with pg_parquet if you're looking to export data to other locations you can drop it off in your data lake to then be processed by other engines such as Snowflake, Clickhouse, Redshift, or if you want something Postgres native <a href=https://www.crunchydata.com/products/warehouse>Crunchy Data Warehouse</a>.<h2 id=what-is-parquet><a href=#what-is-parquet>What is Parquet?</a></h2><p>Heard about Parquet but not sure what it is? Parquet is an open standard file format that is self documenting for data types and comes with columnar compression. It is a flat file - so a file at point in time of the data you're working with or a subset of your tables. If you're looking to leverage cloud storage for a full database, consider looking into Apache Iceberg which applies a metadata layer and catalog on top of parquet. For simply moving data around, pg_parquet integrates Postgres and parquet with a simple sql handshake.<h2 id=working-with-pg_parquet><a href=#working-with-pg_parquet>Working with pg_parquet</a></h2><p>Pg_parquet hooks into Postgres to now provide support for moving data in and out cloud storage via the Postgres <code>copy</code> command. Work with <code>copy</code> just like you normally work.<pre><code class=language-sql>-- Copy a Postgres query result into a Parquet file
COPY (SELECT * FROM table) TO '/tmp/data.parquet' WITH (format 'parquet');

-- Copy a Postgres query result into Parquet in S3
COPY (SELECT * FROM table) TO '
[s3://mybucket/data.parquet](s3://mybucket/data.parquet)'
WITH (format 'parquet');

-- Load data from Parquet in S3 to Postgres
COPY table FROM '
[s3://mybucket/data.parquet](s3://mybucket/data.parquet)'
WITH (format 'parquet');
</code></pre><h2 id=conclusion><a href=#conclusion>Conclusion</a></h2><p>With version 0.4, pg_parquet continues to simplify the process of moving data between Postgres and Parquet. If you’re archiving data, populating a lakehouse, or bridging systems together for data analytics, pg_parquet has a wide variety of use cases. Now that pg_parquet supports all of the public cloud storage areas and a wide variety of data types, it is ready to be integrated into modern data workflows. Also, using <code>COPY</code> in Postgres, means that pg_parquet is lightweight, performant, and Postgres native.<p>We’re excited to see how the community puts this release to use and look forward to what’s next. Contributions and feedback are always welcome on <a href=https://github.com/CrunchyData/pg_parquet>GitHub</a>. ]]></content:encoded>
<category><![CDATA[ Analytics ]]></category>
<author><![CDATA[ Aykut.Bozkurt@crunchydata.com (Aykut Bozkurt) ]]></author>
<dc:creator><![CDATA[ Aykut Bozkurt ]]></dc:creator>
<guid isPermalink="false">82362beb9260c0550cf01b67bcbad5c45f02ec1d348321503ed1df7265a40b00</guid>
<pubDate>Wed, 07 May 2025 08:00:00 EDT</pubDate>
<dc:date>2025-05-07T12:00:00.000Z</dc:date>
<atom:updated>2025-05-07T12:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Logical replication from Postgres to Iceberg ]]></title>
<link>https://www.crunchydata.com/blog/logical-replication-from-postgres-to-iceberg</link>
<description><![CDATA[ We've launched native logical replication from Postgres tables in any Postgres server to Iceberg tables managed by Crunchy Data Warehouse. ]]></description>
<content:encoded><![CDATA[ <p>Operational and analytical workloads have historically been handled by separate database systems, though they are starting to converge. We built <a href=https://www.crunchydata.com/products/warehouse>Crunchy Data Warehouse</a> to put PostgreSQL at the frontier of analytics systems, using modern technologies like <a href=https://iceberg.apache.org/>Iceberg</a> and a <a href=https://www.crunchydata.com/blog/postgres-powered-by-duckdb-the-modern-data-stack-in-a-box>hybrid query engine</a>.<p>Combining operational and analytical capabilities is extremely useful, but it is not meant to drive all your workloads into a single system. In most organizations, application developers and analysts work in different teams with different requirements on data modeling, resource management, operational practices, and various other aspects.<p>What will always be needed is a way to bring data and the stream of changes from an operational database into a separate analytics system. As it turns out, if both sides are PostgreSQL, magical things can happen…<p>Today, we are announcing the availability of native logical replication from Postgres tables in any Postgres server to Iceberg tables managed by Crunchy Data Warehouse.<p>The latest release of Crunchy Data Warehouse includes full support for:<ul><li>Insert, update, delete, and truncate replication into Iceberg<li>High transaction rates<li>Low &#60 60 second apply lag<li>Preservation of transaction boundaries–foreign key constraints still hold<li>Automatic table creation and data copy<li>Automatic compaction<li>Advanced replication protocol features like <a href=https://www.postgresql.org/docs/current/logical-replication-row-filter.html>row filters</a>, <a href=https://www.postgresql.org/docs/current/sql-createsubscription.html>streaming</a> (v4 protocol), and <a href=https://www.postgresql.org/docs/current/logical-replication-failover.html>failover slots</a>.<li>Automatic handling of TOAST columns<li>Ability to rebuild tables while old data remains readable</ul><p>While it sounds like something from the future, logical replication to Iceberg is available right now on <a href=https://www.crunchydata.com/products/crunchy-bridge>Crunchy Bridge</a>, and will be available for self-managed users in the next release of <a href=https://www.crunchydata.com/products/crunchy-postgresql-for-kubernetes>Crunchy Postgres for Kubernetes</a>.<h2 id=setting-up-logical-replication-into-iceberg><a href=#setting-up-logical-replication-into-iceberg>Setting up logical replication into Iceberg</a></h2><p>Getting started with logical replication to Iceberg is very simple. You can literally set up everything with just 2 commands.<p>On the source:<pre><code class=language-sql>create publication pub for table chats, users;
</code></pre><p>On Crunchy Data Warehouse, after ensuring connectivity to the source:<pre><code class=language-sql>create subscription sub connection '...' publication pub with (create_tables_using = 'iceberg');
</code></pre><p>The create subscription command will create Iceberg tables for all tables in the publication, then copy the initial data in the background, and then replicate changes. You can also set up the Iceberg tables manually before creating the subscription.<p>You can run high performance analytical queries and data transformations directly on the Iceberg tables in Crunchy Data Warehouse once the initial data copy completes, or use other query engines with the SQL/JDBC Iceberg catalog driver.</p><video autoplay loop muted playsinline>
<source src="/blog-assets/logical-replication-from-postgres-to-iceberg/2025-04-22-bridge-logicalrep-sidebyside-demo.mp4" type="video/mp4" />
</video><h2 id=how-postgres-to-iceberg-replication-works><a href=#how-postgres-to-iceberg-replication-works>How Postgres-to-Iceberg replication works</a></h2><p>Conventional tools for applying a stream of changes to a data warehouse take large batches and apply them using merge commands. While effective, the computational cost of running these commands is relatively high, and increases significantly as the table grows.<p>We invented several new techniques to apply insertions and deletions to Iceberg in micro batches by taking advantage of Postgres’ transactional capabilities. Queries use an efficient merge-on-read method to apply deletions. Insertion and deletion files are later merged during automatic compaction, and compaction only accesses files that were (significantly) modified.<p>What that means is that replication can be sustained with relatively low lag and low overhead. The main cost is that the replication requires some disk space, though usually much less than the source data.<h2 id=get-started-with-replication-to-your-postgres-data-warehouse><a href=#get-started-with-replication-to-your-postgres-data-warehouse>Get started with replication to your Postgres Data Warehouse</a></h2><p>Our goal is to bring all PostgreSQL features and extensions to Iceberg with high performance analytics. Logical replication is a useful Postgres feature that becomes essential in the context of a data warehouse, given the need to synchronize data from operational databases.<p>Of course, PostgreSQL isn’t perfect. Where possible we try to go the extra mile to build a seamless experience, for instance by enabling automatic Iceberg table creation in <code>CREATE SUBSCRIPTION</code>. There are many other ways in which we think the logical replication experience can be improved, especially for Iceberg, so this is the start of a journey.<p>If you want to get started with this seamless Postgres -> Iceberg replication experience we encourage you to <a href=https://www.crunchydata.com/contact>reach out to us</a> or <a href=https://docs.crunchybridge.com/warehouse/replication>check out the documentation</a>. ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<category><![CDATA[ Analytics ]]></category>
<author><![CDATA[ Marco.Slot@crunchydata.com (Marco Slot) ]]></author>
<dc:creator><![CDATA[ Marco Slot ]]></dc:creator>
<guid isPermalink="false">1647a97e6cfab9bfa8d97db5ea5af21c61d4d2f6383d4c3f8138145a91eb309d</guid>
<pubDate>Tue, 22 Apr 2025 09:00:00 EDT</pubDate>
<dc:date>2025-04-22T13:00:00.000Z</dc:date>
<atom:updated>2025-04-22T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Creating Histograms with Postgres ]]></title>
<link>https://www.crunchydata.com/blog/histograms-with-postgres</link>
<description><![CDATA[ Histograms are elegant tools for visualizing distribution of values. We walkthrough building a re-usable query for your histogram needs. ]]></description>
<content:encoded><![CDATA[ <p>Histograms were first used in a lecture in 1892 by Karl Pearson — the godfather of mathematical statistics. With how many data presentation tools we have today, it’s hard to think that representing data as a graphic was classified as “innovation”, but it was. They are a graphic presentation of the distribution and frequency of data. If you haven’t seen one recently, or don’t know the word histogram off the top of your head - it is a bar chart, each bar represents the count of data with a defined range of values. When Pearson built the first histogram, he calculated it by hand. Today we can use SQL (or even Excel) to extract this data continuously across large data sets.<p>While true statistical histograms have a bit more complexity for choosing bin ranges, for many business intelligence purposes, Postgres <code>width_bucket</code> is good-enough to counting data inside bins with minimal effort.<h2 id=postgres-width_bucket-for-histograms><a href=#postgres-width_bucket-for-histograms>Postgres width_bucket for histograms</a></h2><p>Given the number of buckets and max/min value, <code>width_bucket</code> returns the index for the bucket that a value will fall. For instance, given a minimum value of 0, a maximum value of 100, and 10 buckets, a value of 43 would fall in bucket #5: <code>select width_bucket(43, 0, 100, 10) AS bucket;</code> But 5 is not correct for 43, or is it?<p>You can see how the values would fall using <code>generate_series</code> (shown below using <a href=https://metabase.com>Metabase</a>):<pre><code class=language-sql>SELECT value, width_bucket(value, 0, 100, 10) AS bucket FROM generate_series(0, 100) AS value;
</code></pre><p><img alt="postgres histogram 1-100"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/3aec99f0-696b-4d9b-0c7b-8ce9fc415200/public><p>When running the query, the values 0 through 9 go into bucket 1. As you can see in the image above, <code>width_bucket</code> behaves as a step function that starts indexing with 1. In this scenario, when passed a value of 100, <code>width_bucket</code> returns 11, because the maximum value given the width_bucket is an exclusive range (i.e. the logic is minimum &#60= value &#60 maximum).<p>We can use the bucket value to generate more readable labels.<h2 id=auto-formatting-histogram-with-sql><a href=#auto-formatting-histogram-with-sql>Auto-formatting histogram with SQL</a></h2><p>Let’s build out a larger query that creates ranges, range labels, and formats the histogram. We will start by using a synthetic table within a CTE called <code>formatted_data</code>. We are doing it this way so that we can replace that query with new data in the future.<p>Here’s the beginning of the query (this is copy-pastable into Postgres):<pre><code class=language-sql>WITH formatted_data AS (
  SELECT * FROM (VALUES (13), (42), (18), (62), (93), (47), (51), (41), (1)) AS t (value)
)
SELECT
  WIDTH_BUCKET(value, 0, 100, 10) AS bucket,
  COUNT(value)
FROM formatted_data
  GROUP BY 1
  ORDER BY 1;
</code></pre><p>Let’s use another CTE to define some settings for our <code>width_bucket</code>:<pre><code class=language-sql>WITH formatted_data AS (
  SELECT * FROM (VALUES (13), (42), (18), (62), (93), (47), (51), (41), (1)) AS t (value)
), bucket_settings AS (
	SELECT
		10 as bucket_count,
		0::integer AS min_value, -- can be null::integer or an integer
		100::integer AS max_value -- can be null::integer or an integer
)

SELECT
  WIDTH_BUCKET(value,
	  (SELECT min_value FROM bucket_settings),
		(SELECT max_value FROM bucket_settings),
		(SELECT bucket_count FROM bucket_settings)
	) AS bucket,
  COUNT(value)
FROM formatted_data
  GROUP BY 1
  ORDER BY 1;
</code></pre><p>In the <code>bucket_settings</code> CTE, we use <code>::integer</code> to cast any value there as an integer. We do this since we will want to compare NULL against other integers later. If we don’t cast NULLs then the SQL will fail.<p>Now, we will use a CTE called <code>calculated_bucket_settings</code> to set a dynamic range if the static range is not defined. This will let the data specify the values if they are not defined by the <code>bucket_settings</code>:<pre><code class=language-sql>WITH formatted_data AS (
  SELECT * FROM (VALUES (13), (42), (18), (62), (93), (47), (51), (41), (1)) AS t (value)
), bucket_settings AS (
	SELECT
		5 AS bucket_count,
		null::integer AS min_value, -- can be null or an integer
		null::integer AS max_value -- can be null or an integer
), calculated_bucket_settings AS (
	SELECT
		(SELECT bucket_count FROM bucket_settings) AS bucket_count,
		COALESCE(
			(SELECT min_value FROM bucket_settings),
			(SELECT min(value) FROM formatted_data)
		) AS min_value,
		COALESCE(
			(SELECT max_value FROM bucket_settings),
			(SELECT max(value) + 1 FROM formatted_data)
		) AS max_value
), histogram AS (
  SELECT
     WIDTH_BUCKET(value, min_value, max_value, (SELECT bucket_count FROM bucket_settings)) AS bucket,
     COUNT(value) AS frequency
   FROM formatted_data, calculated_bucket_settings
   GROUP BY 1
   ORDER BY 1
)

SELECT
   bucket,
   frequency,
   CONCAT(
     (min_value + (bucket - 1) * (max_value - min_value) / bucket_count)::INT,
     ' - ',
     (((min_value + bucket * (max_value - min_value) / bucket_count)) - 1)::INT) AS range
FROM histogram, calculated_bucket_settings;
</code></pre><p>In the <code>histogram</code> CTE, we use <code>max_value + 1</code> because the range of values is treated as an exclusive range. Also, because we are working with integers, when you create the pretty label for the <code>range</code>, we subtracted 1 from the maximum value for the range to reduce confusion from what would appear to be overlapping ranges. This decision fits into the “good-enough for business intelligence” caveats listed above. We could have changed the label logic to be <code>75 &#60= value &#60 94</code> in lieu of the subtraction, but most folks like it see the dash instead of math logic for a histogram.<p>The query above will give results like the following:<pre><code class=language-sql>bucket   | frequency |  range
---------+-----------+---------
       1 |         3 | 1 - 18
       3 |         4 | 38 - 55
       4 |         1 | 56 - 74
       5 |         1 | 75 - 93
(4 rows)
</code></pre><p>Now we see that all buckets and frequencies are not represented. So, if a value is empty, we need to fill in the frequency with a zero. This is where SQL requires thinking in sets. We can use <code>generate_series</code> to generate all values for the buckets, then join the histogram to all values. Flipping the order of the query around makes it simpler than joining an incomplete set. In the following query, we’ve built out the buckets in the <code>all_buckets</code> CTE, then joined that to the histogram values:<pre><code class=language-sql>WITH formatted_data AS (
  SELECT * FROM (VALUES (13), (42), (18), (62), (93), (47), (51), (41), (1)) AS t (value)
), bucket_settings AS (
  SELECT
        5 AS bucket_count,
        0::integer AS min_value, -- can be null or an integer
        100::integer AS max_value -- can be null or an integer
), calculated_bucket_settings AS (
	SELECT
	  (SELECT bucket_count FROM bucket_settings) AS bucket_count,
	  COALESCE(
	          (SELECT min_value FROM bucket_settings),
	          (SELECT min(value) FROM formatted_data)
	  ) AS min_value,
	  COALESCE(
	          (SELECT max_value FROM bucket_settings),
	          (SELECT max(value) + 1 FROM formatted_data)
	  ) AS max_value
), histogram AS (
  SELECT
    WIDTH_BUCKET(value, calculated_bucket_settings.min_value, calculated_bucket_settings.max_value + 1, (SELECT bucket_count FROM bucket_settings)) AS bucket,
    COUNT(value) AS frequency
  FROM formatted_data, calculated_bucket_settings
  GROUP BY 1
  ORDER BY 1
 ), all_buckets AS (
  SELECT
    fill_buckets.bucket AS bucket,
    FLOOR(calculated_bucket_settings.min_value + (fill_buckets.bucket - 1) * (calculated_bucket_settings.max_value - calculated_bucket_settings.min_value) / (SELECT bucket_count FROM bucket_settings)) AS min_value,
    FLOOR(calculated_bucket_settings.min_value + fill_buckets.bucket * (calculated_bucket_settings.max_value - calculated_bucket_settings.min_value) / (SELECT bucket_count FROM bucket_settings)) AS max_value
  FROM calculated_bucket_settings,
	  generate_series(1, calculated_bucket_settings.bucket_count) AS fill_buckets (bucket))

 SELECT
   all_buckets.bucket AS bucket,
   CASE
   WHEN all_buckets IS NULL THEN
	   'out of bounds'
	 ELSE
     CONCAT(all_buckets.min_value, ' - ', all_buckets.max_value - 1)
   END AS range,
   SUM(COALESCE(histogram.frequency, 0)) AS frequency
 FROM all_buckets
 FULL OUTER JOIN histogram ON all_buckets.bucket = histogram.bucket
 GROUP BY 1, 2
 ORDER BY bucket;
</code></pre><p>Try modifying the values in the <code>bucket_settings</code> CTE to see how the histogram responds. By increasing the <code>bucket_count</code>, <code>min_value</code>, or <code>max_value</code>, you’ll see the histogram respond appropriately. If you modify the range to exclude values, using the <code>FULL OUTER JOIN</code>, you’ll see that all non-classified items are bucketed as “out of bounds”.<p>Using a presentation tool, display the histogram as a bar chart (shown below using <a href=https://metabase.com>Metabase</a>):<p><img alt="postgres histogram"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/fb95ee22-4d7d-40c5-3d2c-74a610cbd000/public><h2 id=real-life-data-with-histograms><a href=#real-life-data-with-histograms>Real Life Data with Histograms</a></h2><p>Now that we have a really nice auto-adjusting query, we can simply build a histogram from other examples. I have a little experimental database from the <a href=https://aact.ctti-clinicaltrials.org/download>database of clinical trials</a>.<p>What if we wanted to build a histogram for the count of participants in various clinical trial studies? To start, build the query that finds the number of participants for each study:<pre><code class=language-sql>SELECT
	outcomes.nct_id,
	max(outcome_counts.count) AS value
FROM outcomes
INNER JOIN outcome_counts ON outcomes.id = outcome_counts.outcome_id
WHERE param_type = 'COUNT_OF_PARTICIPANTS'
GROUP BY 1
</code></pre><p>We can take the above query, and place it in the <code>formatted_data</code> CTE:<pre><code class=language-sql>WITH formatted_data AS (
	SELECT
		outcomes.nct_id,
		MAX(outcome_counts.count) AS value
	FROM outcomes
	INNER JOIN outcome_counts ON outcomes.id = outcome_counts.outcome_id
	WHERE param_type = 'COUNT_OF_PARTICIPANTS'
	GROUP BY 1
), bucket_settings AS (
  SELECT
        20 AS bucket_count,
        null::integer AS min_value, -- can be null or an integer
        null::integer AS max_value -- can be null or an integer
), calculated_bucket_settings AS (
	SELECT
	  (SELECT bucket_count FROM bucket_settings) AS bucket_count,
	  COALESCE(
	          (SELECT min_value FROM bucket_settings),
	          (SELECT min(value) FROM formatted_data)
	  ) AS min_value,
	  COALESCE(
	          (SELECT max_value FROM bucket_settings),
	          (SELECT max(value) + 1 FROM formatted_data)
	  ) AS max_value
), histogram AS (
  SELECT
    WIDTH_BUCKET(value, calculated_bucket_settings.min_value, calculated_bucket_settings.max_value + 1, (SELECT bucket_count FROM bucket_settings)) AS bucket,
     COUNT(value) AS frequency
   FROM formatted_data, calculated_bucket_settings
   GROUP BY 1
   ORDER BY 1
 ), all_buckets AS (
   SELECT
     fill_buckets.bucket AS bucket,
     FLOOR(calculated_bucket_settings.min_value + (fill_buckets.bucket - 1) * (calculated_bucket_settings.max_value - calculated_bucket_settings.min_value) / (SELECT bucket_count FROM bucket_settings)) AS min_value,
     FLOOR(calculated_bucket_settings.min_value + fill_buckets.bucket * (calculated_bucket_settings.max_value - calculated_bucket_settings.min_value) / (SELECT bucket_count FROM bucket_settings)) AS max_value
   FROM calculated_bucket_settings,
	   generate_series(1, calculated_bucket_settings.bucket_count) AS fill_buckets (bucket))

 SELECT
   all_buckets.bucket AS bucket,
   CASE
   WHEN all_buckets IS NULL THEN
	   'out of bounds'
	 ELSE
     CONCAT(all_buckets.min_value, ' - ', all_buckets.max_value - 1)
   END AS range,
   SUM(COALESCE(histogram.frequency, 0)) AS frequency
 FROM all_buckets
 FULL OUTER JOIN histogram ON all_buckets.bucket = histogram.bucket
 GROUP BY 1, 2
 ORDER BY bucket;
</code></pre><p>The query will output the following. This is a bit un-desirable because the distribution is concentrated in the first bucket:<pre><code class=language-sql> bucket |       range       | frequency
--------+-------------------+-----------
      1 | 1 - 359943        |     23261
      2 | 359944 - 719886   |         3
      3 | 719887 - 1079829  |         1
      4 | 1079830 - 1439773 |         0
      5 | 1439774 - 1799716 |         1
      6 | 1799717 - 2159659 |         0
      7 | 2159660 - 2519602 |         0
      8 | 2519603 - 2879546 |         0
      9 | 2879547 - 3239489 |         0
     10 | 3239490 - 3599432 |         0
     11 | 3599433 - 3959375 |         0
     12 | 3959376 - 4319319 |         0
     13 | 4319320 - 4679262 |         0
     14 | 4679263 - 5039205 |         0
     15 | 5039206 - 5399148 |         0
     16 | 5399149 - 5759092 |         0
     17 | 5759093 - 6119035 |         0
     18 | 6119036 - 6478978 |         0
     19 | 6478979 - 6838921 |         0
     20 | 6838922 - 7198865 |         1
(20 rows)

</code></pre><p>If you’ve loaded the data, to improve the presentation, we can adjust the <code>bucket_settings</code> CTE to modify how the buckets are defined. For instance, with this dataset, if we changed the bucket settings to:<pre><code class=language-sql>  SELECT
        20 AS bucket_count,
        0::integer AS min_value, -- can be null or an integer
        100::integer AS max_value -- can be null or an integer
</code></pre><p>It outputs a much nicer distribution of data:<pre><code class=language-sql> bucket |     range     | frequency
--------+---------------+-----------
      1 | 0 - 49        |     13584
      2 | 50 - 99       |      3612
      3 | 100 - 149     |      1720
      4 | 150 - 199     |       942
      5 | 200 - 249     |       645
      6 | 250 - 299     |       477
      7 | 300 - 349     |       338
      8 | 350 - 399     |       237
      9 | 400 - 449     |       176
     10 | 450 - 499     |       137
     11 | 500 - 549     |       150
     12 | 550 - 599     |       101
     13 | 600 - 649     |        77
     14 | 650 - 699     |        58
     15 | 700 - 749     |        61
     16 | 750 - 799     |        41
     17 | 800 - 849     |        41
     18 | 850 - 899     |        33
     19 | 900 - 949     |        36
     20 | 950 - 999     |        43
        | out of bounds |       758
</code></pre><h2 id=in-brief><a href=#in-brief>In brief</a></h2><ul><li>Using Postgres <code>width_bucket</code> will build buckets to gather frequency values to create histograms.<ul><li>Creating a function assigns values to predefined buckets based on a min/max range and bucket count.<li>By casting, you can work with data that contains some null values<li>You can create values that fall outside the defined range</ul><li>By using Common Table Expressions (CTEs), you can define bucket settings dynamically with auto-adjusting bins based on the dataset.<li>Histograms can aid with the visualization of data and data distribution in your set. Histograms show how frequently data points appear within specific ranges (bins), making it easier to understand patterns, trends, and outliers. Bin size does affect interpretation so choosing the right number of bins is crucial; too few can oversimplify the data, while too many can create noise and obscure trends.</ul><p>Build an interesting histogram? Show us <a href=https://x.com/crunchydata>@crunchydata</a>! ]]></content:encoded>
<category><![CDATA[ Analytics ]]></category>
<author><![CDATA[ Christopher.Winslett@crunchydata.com (Christopher Winslett) ]]></author>
<dc:creator><![CDATA[ Christopher Winslett ]]></dc:creator>
<guid isPermalink="false">62085653255fdab2276832f69926de399f4b9c4e76871d17191be67b2a96104d</guid>
<pubDate>Fri, 04 Apr 2025 10:00:00 EDT</pubDate>
<dc:date>2025-04-04T14:00:00.000Z</dc:date>
<atom:updated>2025-04-04T14:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Reducing Cloud Spend: Migrating Logs from CloudWatch to Iceberg with Postgres ]]></title>
<link>https://www.crunchydata.com/blog/reducing-cloud-spend-migrating-logs-from-cloudwatch-to-iceberg-with-postgres</link>
<description><![CDATA[ How we migrated our internal logging for our database as a service, Crunchy Bridge, from CloudWatch to S3 with Iceberg and Postgres. The result was simplified logging management, better access with SQL, and significant cost savings. ]]></description>
<content:encoded><![CDATA[ <p>As a database service provider, we store a number of logs internally to audit and oversee what is happening within our systems. When we started out, the volume of these logs is predictably low, but with scale they grew rapidly. Given the number of databases we run for users on Crunchy Bridge, the volume of these logs has grown to a sizable amount. Until last week, we retained those logs in AWS CloudWatch. Spoiler alert: this is expensive.<p>While we have a number of strategies to drive efficiency around the logs, we retain and we regularly remove unnecessary noise or prune old logs. That growth has driven AWS CloudWatch to represent a sizable portion of our infrastructure spend.<p>Going forward, we now have a new workflow that makes use of low cost S3 storage with Iceberg tables and the power and simplicity of <a href=https://www.crunchydata.com/products/warehouse>Crunchy Data Warehouse</a>, which has <strong>reduced our spend on logging by over $30,000 a month</strong>.<p>Using this new workflow, we can simply:<ul><li>archive logs directly into S3<li>incrementally load those logs into Iceberg via Crunchy Data Warehouse<li>use SQL to query the logs required using Crunchy Data Warehouse</ul><p>The crux of any log ingestion service is more or less: ingest log traffic, index the data, offload the logs to more cost efficient storage, and, when necessary, access later.<p>Historically, we used AWS CloudWatch but there are many logging services available. These services offer a range of capabilities, but come with a price tag representing a premium to the cost of storing logs directly in S3. While simply exporting logs to S3 always represented a potential cost savings, without a query engine to efficiently investigate these logs when required, exporting logs to S3 was not previously a viable solution.  Crunchy Data Warehouse's ability to easily query S3 was the breakthrough we needed.<h2 id=setting-up-logs-with-s3-and-iceberg><a href=#setting-up-logs-with-s3-and-iceberg>Setting up logs with S3 and Iceberg</a></h2><p>The first step? Get all of our logs flowing into S3.<p>Every server in our fleet, whether that be a server running our customer’s Postgres workloads or the servers that make up the Crunchy Bridge service itself, is running a logging process that continuously collects a variety of logs. The logs are generated from various sources. A few examples are SSH access, the Linux kernel, and Postgres. These logs all have different schemas and encodings that the logging agent transforms into a consistent CSV structure before batching and flushing them to durable, long-term storage. Once these logs make it off host, they are indexed and stored where they can be queried as needed.<p>Now that we have our logs flowing in S3, we provision a Crunchy Data Warehouse  so we can:<ol><li><p>Move the data from CSV to Iceberg for better compression<li><p>Query our logs using standard SQL with Postgres.</ol><p>Once the warehouse is provisioned, create a foreign table from within Crunchy Data Warehouse called logs that points at the S3 bucket's CSV files:<pre><code class=language-sql>create foreign table logs (
   /* column names and types */
)
server crunchy_lake_analytics
options (path 's3://crunchy-bridge/tmp/*.tsv.gz', format 'csv', compression 'gzip', delimiter E'\t', filename 'true');
</code></pre><p>Now we create a fully managed Iceberg table that is an exact copy of the foreign table referencing the CSVs. Here Iceberg is beneficial because it will automatically compress the data into parquet files of 512 MB per file, know how to add data easily across files, push down queries that are targeting only a narrow window. Essentially, we've gone from CSV to columnar file format and from flat files to a full database:<pre><code class=language-sql>-- Create an Iceberg table with the same schema
create table logs_iceberg (like logs)
using iceberg;
</code></pre><p>Finally, we're going to layer in the open source extension <code>pg_incremental</code>. <a href=https://github.com/CrunchyData/pg_incremental>Pg_incremental</a> is a Postgres extension that makes it easy to do fast, reliable incremental batch processing within Postgres. <code>pg_incremental</code> is most commonly used for incremental rollups of data. In this case it is equally useful for processing new CSV data as it arrives and moving it into our Iceberg table within S3–connected to Postgres.<pre><code class=language-sql>-- Set up a pg_incremental job to process existing files and automatically process new files every hour
select incremental.create_file_list_pipeline('process-logs',
   file_pattern := 's3://crunchy-bridgetmp/*.tsv.gz',
   batched := true,
   max_batch_size := 20000,
   schedule := '@hourly',
   command := $$
       insert into logs_iceberg select * from logs where _filename = any($1)
   $$);
</code></pre><h2 id=final-thoughts><a href=#final-thoughts>Final thoughts</a></h2><p>And there you have it! Cheaper, cleaner log management. As one of my colleagues described it: “personally, I always hated the imitation SQL query languages of logging providers–just get me real SQL”. Between using SQL to query logs, to simplifying our stack, to the cost savings - this project showcases some of our favorite things about Crunchy Data Warehouse.<p>We often get questions on the architecture of Crunchy Bridge. We have talked about it <a href="https://www.youtube.com/watch?v=eZypM_4xlf8">a bit</a>. The short version is that Crunchy Bridge is built from the ground up using public cloud primitives to create a highly scalable and efficiently managed Postgres service. At the time, AWS CloudWatch was chosen due to the lack of better options. We don't want to be a logging provider, it's a fundamentally different business. But seeing how well this works, who knows 😉 ]]></content:encoded>
<category><![CDATA[ Analytics ]]></category>
<category><![CDATA[ Crunchy Data Warehouse ]]></category>
<author><![CDATA[ Craig.Kerstiens@crunchydata.com (Craig Kerstiens) ]]></author>
<dc:creator><![CDATA[ Craig Kerstiens ]]></dc:creator>
<guid isPermalink="false">b188e0c71dd1df0e2080dc50599a01b101fefc3ebbb681b861fa9bf7490f0bda</guid>
<pubDate>Wed, 26 Mar 2025 12:00:00 EDT</pubDate>
<dc:date>2025-03-26T16:00:00.000Z</dc:date>
<atom:updated>2025-03-26T16:00:00.000Z</atom:updated></item></channel></rss>