<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	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/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Business Intelligence | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/business-intelligence/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/business-intelligence/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Tue, 04 Aug 2026 09:34:17 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.3</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Business Intelligence | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/business-intelligence/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Data Lake vs Data Warehouse for CRM Analytics: Volume Is the Wrong Question</title>
		<link>https://john-nessime.com/blog/devops/data-lake-vs-data-warehouse-crm-analytics/</link>
					<comments>https://john-nessime.com/blog/devops/data-lake-vs-data-warehouse-crm-analytics/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 06 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon Redshift]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Apache Iceberg]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[CRM Analytics]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Lakehouse]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[Zero-ETL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=153</guid>

					<description><![CDATA[<p>Everyone argues this one on data volume, and volume is the argument that matters least: CRM data is small enough that both architectures handle it comfortably. What actually decides data lake vs data warehouse for CRM analytics is how much point-in-time history you need, how fast the schema churns, what shape your queries are, and who is going to maintain the thing. Includes a decision procedure you can run in an afternoon.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/data-lake-vs-data-warehouse-crm-analytics/">Data Lake vs Data Warehouse for CRM Analytics: Volume Is the Wrong Question</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The question never arrives as an architecture question. It arrives as someone in RevOps asking whether they can get pipeline coverage by rep, by week, for the last two years, including the deals that were deleted along the way.</p>



<p class="wp-block-paragraph">You look at the CRM reporting tab. It cannot do that. So now you are picking a platform, and within about ten minutes someone will say &#8220;we should build a data lake,&#8221; and someone else will say &#8220;just put it in the warehouse,&#8221; and the deciding argument will be about how much data there is.</p>



<p class="wp-block-paragraph">That argument is almost always the wrong one. This post covers data lake vs data warehouse specifically for CRM analytics: why volume rarely decides it, what actually does, a decision procedure you can run in an afternoon, and where zero-ETL has changed the maths recently enough that older advice is stale.</p>



<h2 class="wp-block-heading">Start with the number, then stop using it</h2>



<p class="wp-block-paragraph">Go and count your rows. Not estimate. Count.</p>



<p class="wp-block-paragraph">A mid-sized company&#8217;s entire Salesforce org is usually a few million rows across the objects anyone actually reports on: accounts, contacts, opportunities, activities, cases. Add HubSpot, Zendesk and a billing system and you are still typically in the low tens of gigabytes once it is written as compressed Parquet. Activity and event history is the one object that can genuinely run large, and it is worth checking separately.</p>



<p class="wp-block-paragraph">Numbers at that scale do not stress either architecture. A data warehouse will not break a sweat. A lake will not break a sweat. The lake-versus-warehouse debate as it is usually framed, the one about petabytes and elasticity and separating storage from compute, is a debate about a scale problem you do not have.</p>



<p class="wp-block-paragraph">So count the rows, confirm you are small, and then stop using volume as an argument. Everything below is about what decides it instead.</p>



<h2 class="wp-block-heading">Data lake vs data warehouse: the three options, honestly</h2>



<h3 class="wp-block-heading">Warehouse only</h3>



<p class="wp-block-paragraph">Load CRM objects straight into Redshift, Snowflake, BigQuery or Postgres. Model with SQL. Point the BI tool at it.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> it is the fastest path to an analyst being productive, and that is not a small thing. Query performance on joins is better than a lake gives you without tuning. Access control is mature and row-level. Every BI tool connects natively. The people who will maintain this already know SQL and probably do not know Spark.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> semi-structured data is awkward, and CRM payloads are full of it. Custom field bags, JSON blobs, email bodies, call transcripts. You can store JSON in a modern warehouse but you will not enjoy querying it at any depth. Storing many years of daily snapshots gets expensive on warehouse storage in a way it does not on object storage. And you are more locked in: moving between warehouses is a migration project, not a repoint.</p>



<h3 class="wp-block-heading">Lake only</h3>



<p class="wp-block-paragraph">Land raw API responses to S3, catalogue them, query with Athena or Spark. Iceberg or Delta as the table format if you want anything resembling a database.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> raw retention is nearly free, so keeping every version of every record for years is a storage line item rather than a decision. Schema evolution is genuinely easier: when a SaaS admin adds a field, the lake absorbs it. Unstructured content lives alongside structured tables, which matters more now that people want to run models over call notes and support tickets. And an open table format means the data is not hostage to one query engine.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> you have taken on work a warehouse does for you. Small-file compaction, partitioning, table maintenance, catalogue hygiene. Query latency on interactive dashboards is worse unless you invest in layout. Governance is a build, not a setting. And the per-query cost model punishes exactly the workload CRM analytics generates: lots of small, repeated, unpredictable queries from a BI tool. Athena bills on data scanned with a per-query minimum, which means a dashboard refreshing thirty tiles is thirty billable scans whether or not anyone is looking.</p>



<h3 class="wp-block-heading">Lakehouse</h3>



<p class="wp-block-paragraph">Raw and historical data in object storage as Iceberg tables; curated marts in the warehouse or queried in place by a warehouse engine. This is where most serious setups end up, and it is what AWS, Databricks and Snowflake have all converged on.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> you get cheap deep history and expensive fast queries in the right places. Raw payloads stay replayable when a vendor changes a field type. Analysts still get warehouse-shaped tables.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> it is two systems. Two cost models, two sets of permissions, two places a table can be defined and drift. For a five-person company with one CRM, this is architecture as procrastination. The complexity is real and it should be earning something.</p>



<h2 class="wp-block-heading">What actually decides it</h2>



<p class="wp-block-paragraph">Four levers, in the order they usually matter for CRM data.</p>



<h3 class="wp-block-heading">1. How much history you need, and whether deletes matter</h3>



<p class="wp-block-paragraph">This is the big one and it is the one people skip.</p>



<p class="wp-block-paragraph">CRM systems mutate in place. An opportunity&#8217;s stage, amount and close date are overwritten as the deal moves. If you only ever sync current state, you can report on the pipeline as it is today and you cannot report on what it looked like last March. Every question about pipeline movement, forecast accuracy, stage velocity or &#8220;what changed since the QBR&#8221; needs point-in-time history, and the CRM will not give it to you retroactively. Deleted records are worse: they are simply gone.</p>



<p class="wp-block-paragraph">If you need history, you need somewhere cheap to keep daily snapshots or a change feed. That pushes toward object storage, or toward a warehouse with a genuinely cheap storage tier. If a rolling twelve months of current-state data covers every question anyone asks, the warehouse handles it alone.</p>



<h3 class="wp-block-heading">2. How fast the schema churns</h3>



<p class="wp-block-paragraph">Count the custom fields added to your main CRM object in the last six months. If it is two, schema churn is not your problem. If it is forty, and half of them are from a business unit you do not talk to, a rigid warehouse schema becomes a standing maintenance tax and the lake&#8217;s tolerance for drift starts paying for itself.</p>



<h3 class="wp-block-heading">3. The shape of the queries</h3>



<p class="wp-block-paragraph">Scheduled dashboards hitting the same tables repeatedly favour a warehouse, because per-second compute amortises across many queries and results are cached. Redshift Serverless, for example, bills in processing-unit-hours per second with a short minimum and shuts down when idle, which suits bursty BI traffic.</p>



<p class="wp-block-paragraph">Exploratory one-off analysis across wide, rarely-touched history favours a lake, because you only pay when someone asks. Most CRM analytics is overwhelmingly the first kind. That fact alone settles a lot of these arguments.</p>



<h3 class="wp-block-heading">4. Who is going to maintain it</h3>



<p class="wp-block-paragraph">The least technical criterion and often the most decisive. If the answer is &#8220;a RevOps analyst who is very good at SQL and has no platform team behind them,&#8221; build the warehouse. A lake handed to someone who cannot maintain it becomes an unqueryable pile of Parquet in about eight months. That is not a hypothetical failure mode; it is the normal one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A decision procedure you can run this afternoon</h2>



<ol class="wp-block-list">
<li><strong>Count rows per object</strong> for everything anyone reports on. Note which objects are over about ten million rows. Usually it is activities and nothing else.</li>

<li><strong>Write down the ten questions</strong> the business actually asks. Not the ones you imagine. Mark each one as needing current state or point-in-time history.</li>

<li><strong>Count schema changes</strong> on your main objects over the last six months.</li>

<li><strong>Estimate query volume:</strong> number of dashboards, tiles per dashboard, refresh frequency, plus rough ad-hoc queries per week. This is the number that drives cost, not storage.</li>

<li><strong>Model both costs against those numbers.</strong> For the lake: bytes scanned per query times queries per month. For the warehouse: compute-hours per month plus storage. Do it on a napkin. At CRM scale the answer is usually obvious and usually favours the warehouse.</li>

<li><strong>Name the maintainer.</strong> If you cannot name a person, pick the option that needs less of them.</li>
</ol>



<p class="wp-block-paragraph">Read together, most CRM-only shops land on: warehouse for the curated layer, cheap object storage for raw history, and no ambition beyond that until something forces it. That is a lakehouse, but the small honest kind, and it is a very different commitment from standing up a full lake platform.</p>



<h2 class="wp-block-heading">Where zero-ETL changed the maths</h2>



<p class="wp-block-paragraph">A lot of writing on this topic predates a genuine shift, so it is worth stating plainly.</p>



<p class="wp-block-paragraph">Managed zero-ETL integrations now replicate CRM and support platforms into AWS targets without you building a pipeline. AWS Glue supports SaaS sources including Salesforce, SAP, ServiceNow, Zendesk and Zoho CRM, landing into Amazon Redshift, the SageMaker lakehouse, or S3 Tables with Iceberg underneath. Salesforce has pushed the other direction with zero-copy federation, letting Data Cloud read Iceberg tables in Databricks, Snowflake, BigQuery or Redshift in place, and letting those platforms read Data Cloud objects back.</p>



<p class="wp-block-paragraph">Two consequences for this decision:</p>



<ul class="wp-block-list">
<li><strong>Ingestion is no longer the differentiator.</strong> &#8220;The lake is easier to load into&#8221; was a real argument when you were hand-rolling connectors. It is much weaker when the same managed integration targets either destination.</li>

<li><strong>Iceberg makes the choice less permanent.</strong> If your raw layer is Iceberg tables in object storage, adding a warehouse engine later is a configuration change rather than a migration. That is a genuine reason to prefer open table formats even if you never build a &#8220;lake&#8221; as such.</li>
</ul>



<p class="wp-block-paragraph">Two honest caveats. Zero-ETL integrations cover a curated list of sources and regions, so check yours before designing around them. And a managed replica is still current-state replication plus change capture, not a modelled history; you still have to decide how snapshots and slowly changing dimensions get built.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;We need a lake because we&#8217;ll do AI later.&#8221;</strong> Reasonable in spirit, wrong in sequence. Build the thing that answers this quarter&#8217;s questions, keep raw payloads in object storage so you have not destroyed your options, and revisit. Raw retention is the cheap insurance; a lake platform is not.</li>

<li><strong>&#8220;Storage is cheaper in a lake.&#8221;</strong> True and almost always irrelevant. At CRM volumes, storage is a rounding error next to compute and, more to the point, next to the salary of whoever maintains the thing.</li>

<li><strong>&#8220;The warehouse is a single point of vendor lock-in.&#8221;</strong> Fair, and it is why open table formats matter. But if you replicate raw data to object storage, the lock-in is on your transformation logic, not your data, and that is portable if you keep it in SQL and version control.</li>

<li><strong>&#8220;We&#8217;ll just query the CRM API directly and skip both.&#8221;</strong> Works until a dashboard fans out into rate limits and the whole thing becomes a data pipeline you did not design. If more than one person is asking questions, you need a copy.</li>

<li><strong>&#8220;Let&#8217;s do both properly from day one.&#8221;</strong> This is the expensive mistake. Two systems, two cost models, and tables that quietly disagree, in service of a workload that fits comfortably in one.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is a data lake overkill for one CRM?</h3>



<p class="wp-block-paragraph">For a single CRM with current-state reporting needs, usually yes. The point at which it stops being overkill is when you need years of point-in-time history, when unstructured content like call transcripts becomes part of the analysis, or when you are integrating enough systems that a common raw layer is cheaper than a dozen warehouse loaders.</p>



<h3 class="wp-block-heading">How do I get historical CRM snapshots if I only have current state?</h3>



<p class="wp-block-paragraph">You mostly cannot get it retroactively, which is why this decision is urgent rather than academic. Start capturing now: either daily full snapshots to partitioned object storage, or a change feed if the platform offers one, then build slowly changing dimension tables from that. Field history tracking inside the CRM covers a limited number of fields and is not a substitute.</p>



<h3 class="wp-block-heading">Does the lakehouse make this comparison obsolete?</h3>



<p class="wp-block-paragraph">It blurs the storage layer, not the operational reality. You still choose a query engine, a cost model, a governance model and a maintainer. The useful question has shifted from &#8220;lake or warehouse&#8221; to &#8220;how much of this do I want to operate myself,&#8221; which is a better question anyway.</p>



<h3 class="wp-block-heading">Where does reverse ETL fit?</h3>



<p class="wp-block-paragraph">If you plan to push computed values back into the CRM, such as a lead score or account health field, that pushes toward the warehouse. Reverse ETL tools connect to warehouses far more readily than to query engines over object storage, and the write path wants a stable, modelled table rather than a raw one.</p>



<h3 class="wp-block-heading">Can I start with one and move later?</h3>



<p class="wp-block-paragraph">Moving from lake to warehouse is straightforward: you are loading modelled tables into a new engine. Moving from warehouse to lake is harder, because the history only exists inside the warehouse and the transformations are written in its dialect. The asymmetric fix is to land raw data in object storage regardless of which you query from. That single habit preserves the option cheaply.</p>



<h3 class="wp-block-heading">What about just using the CRM&#8217;s own analytics product?</h3>



<p class="wp-block-paragraph">Worth pricing seriously before building anything. It is genuinely the right answer when all your questions are about one system, and it removes an entire pipeline from your life. It stops being the right answer the moment a question spans CRM plus billing plus product usage, because you will be paying to ingest the other systems into a platform priced for CRM data.</p>



<h2 class="wp-block-heading">The short version</h2>



<p class="wp-block-paragraph">In the data lake vs data warehouse question for CRM analytics, volume is the argument everyone reaches for and it is the one that matters least. Your CRM data is small. Both options handle it.</p>



<p class="wp-block-paragraph">What decides it is how much history you need, how fast the schema moves, what shape the queries are, and who is going to keep it running. Answer those four and the architecture falls out on its own. And whatever you pick, land the raw payloads in object storage from day one, because that is the cheap decision that keeps every other decision reversible.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second opinion on your CRM analytics stack?</h2>



<p class="wp-block-paragraph">I help teams make this call without over-building, and unpick it when it has already been over-built. Things I can help with:</p>



<ul class="wp-block-list">
<li>Running the sizing and cost model above against your real query volume, not a vendor calculator.</li>

<li>Designing point-in-time history capture for CRM objects, including deletes and slowly changing dimensions.</li>

<li>Setting up zero-ETL or connector-based ingestion from Salesforce, HubSpot, Zendesk or Zoho into Redshift, Snowflake or an Iceberg lake.</li>

<li>Migrating an unmaintainable pile of Parquet into something an analyst can actually query.</li>

<li>Cutting query costs on an existing setup: partitioning, table layout, dashboard refresh patterns.</li>

<li>Building the curated CRM models that BI tools sit on, in SQL and in version control.</li>
</ul>



<p class="wp-block-paragraph">If you are mid-argument about this, send me your row counts and the ten questions the business wants answered. That is usually enough to settle it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/data-lake-vs-data-warehouse-crm-analytics/">Data Lake vs Data Warehouse for CRM Analytics: Volume Is the Wrong Question</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/data-lake-vs-data-warehouse-crm-analytics/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Shopify Sales Dashboard with AWS: Build One That Actually Reconciles</title>
		<link>https://john-nessime.com/blog/technical-guides/shopify-sales-dashboard-aws/</link>
					<comments>https://john-nessime.com/blog/technical-guides/shopify-sales-dashboard-aws/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[AWS Lambda]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Ecommerce Analytics]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[EventBridge]]></category>
		<category><![CDATA[Partition Projection]]></category>
		<category><![CDATA[QuickSight]]></category>
		<category><![CDATA[Serverless]]></category>
		<category><![CDATA[Shopify]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[Webhooks]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=142</guid>

					<description><![CDATA[<p>Most Shopify dashboards built on AWS work perfectly for about three weeks, then quietly drift away from the numbers in the Shopify admin. Here is why that happens, and how to design the ingestion, storage and query layers so your totals still reconcile six months in.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/shopify-sales-dashboard-aws/">Shopify Sales Dashboard with AWS: Build One That Actually Reconciles</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone in the finance channel posts two screenshots side by side. On the left, the dashboard you built. On the right, the Shopify admin. The totals don&#8217;t match, and they&#8217;re not off by a rounding error either. They&#8217;re off by enough that nobody wants to use your dashboard for anything that matters.</p>



<p class="wp-block-paragraph">The frustrating part is that the pipeline is fine. Webhooks are arriving, Lambda is running clean, S3 has the files, Athena returns rows. Nothing is broken in the way monitoring understands &#8220;broken&#8221;. The pipeline is faithfully recording something that is no longer true.</p>



<p class="wp-block-paragraph">This post is about building a Shopify sales dashboard with AWS that survives that conversation. Not the wiring, which is well documented and mostly straightforward, but the design decisions that determine whether your numbers still hold up six months in. I&#8217;ll cover the three ingestion paths and when each one is the right call, why append-only pipelines drift, how to lay out S3 and Athena so recomputation is cheap, and what to do when the totals are already wrong.</p>



<h2 class="wp-block-heading">Why a Shopify sales dashboard with AWS drifts from the admin</h2>



<p class="wp-block-paragraph">Here&#8217;s the thing that catches almost everyone: <strong>a Shopify order is not an event, it&#8217;s a mutable record.</strong></p>



<p class="wp-block-paragraph">An event pipeline assumes facts are immutable once written. A payment happened. A shipment left. You append it, you never touch it again, and the sum of the log is the truth. That model is why streaming architectures are so clean, and it&#8217;s exactly wrong for order data.</p>



<p class="wp-block-paragraph">An order created on Monday can be edited on Tuesday, partially refunded on Friday, and fully refunded three weeks later. Every one of those changes belongs, financially, to Monday. If your pipeline appends the <code>orders/create</code> payload and never revisits it, Monday&#8217;s revenue is frozen at the moment of checkout and it will only ever be too high.</p>



<p class="wp-block-paragraph">This is the invisible failure. Nothing alerts. No queue backs up. Your dashboard is confidently wrong, and the gap widens roughly in proportion to your return rate. A store with a two percent return rate takes a long time to notice. A fashion store running thirty percent returns notices in about a month, usually via an angry accountant.</p>



<h3 class="wp-block-heading">The four adjustments that move historical numbers</h3>



<ul class="wp-block-list">
<li><strong>Refunds.</strong> Full or partial. A refund carries its own <code>created_at</code>, which is when the money moved back. The order it belongs to has a different, earlier date. You need both, and which one you attribute to depends on whether finance wants cash-basis or order-basis reporting. Ask before you build.</li>

<li><strong>Order edits.</strong> A merchant adds a line item or adjusts a quantity after the fact. The original payload is now stale. Shopify exposes both the original and the current totals precisely because of this.</li>

<li><strong>Cancellations.</strong> A cancelled order keeps existing in the API. If you filter only on payment status you will happily keep counting it.</li>

<li><strong>Test and draft orders.</strong> Test orders carry a flag marking them as such. Nobody remembers to filter these until a QA run during a quiet week produces a suspicious spike.</li>
</ul>



<p class="wp-block-paragraph">The design consequence is simple to state and annoying to implement: <strong>your pipeline must be able to recompute any past day.</strong> Every storage and partitioning decision below follows from that one requirement.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Getting data out of Shopify: three paths, three trade-offs</h2>



<p class="wp-block-paragraph">Before anything else: new Shopify apps are built on the GraphQL Admin API. The REST Admin API has been designated a legacy API and new public apps must use GraphQL. If you&#8217;re starting fresh, start there. If you inherited a REST integration, it probably still runs, but you&#8217;re on borrowed time and you should plan the migration rather than discover the deadline.</p>



<h3 class="wp-block-heading">Path 1: EventBridge partner event source</h3>



<p class="wp-block-paragraph">Shopify can deliver webhooks straight into an Amazon EventBridge partner event bus in your account. No public endpoint, no API Gateway, no HMAC verification code, because verification only applies to HTTPS deliveries. Shopify&#8217;s own docs confirm EventBridge and Pub/Sub deliveries skip it.</p>



<p class="wp-block-paragraph">You create the source in the Shopify app configuration using your AWS account ID, region and a source name, then associate it with an event bus in the EventBridge console and write rules to route it. The address you register with Shopify is the <em>partner event source</em> ARN, not the event bus ARN. That distinction accounts for a large share of the &#8220;I set it up and nothing arrives&#8221; threads on the Shopify forums.</p>



<p class="wp-block-paragraph">A rule matching everything from the Shopify partner source looks like this. Start broad, then narrow once you&#8217;ve seen the real shape of an event:</p>



<pre class="wp-block-code"><code>{
  "source": [ { "prefix": "aws.partner/shopify.com" } ]
}</code></pre>



<p class="wp-block-paragraph">Send that to an SQS queue with a dead-letter queue attached rather than straight to Lambda. Buffering gives you a replay buffer when a downstream deploy goes wrong, and the DLQ means a bad payload parks itself instead of poisoning the whole rule. This is the path I reach for first for anything already on AWS.</p>



<h3 class="wp-block-heading">Path 2: HTTPS webhooks into API Gateway and Lambda</h3>



<p class="wp-block-paragraph">The conventional route, and the right one if you need webhook delivery outside AWS too, or you want the payloads to pass through something you fully control. The cost is that you now own an internet-facing endpoint and the HMAC verification on it.</p>



<p class="wp-block-paragraph">Verify against the <strong>raw request body</strong>, before any JSON parsing. Re-serialising the payload changes byte-for-byte content and the signature will never match. Use a constant-time comparison so the check doesn&#8217;t leak timing information:</p>



<pre class="wp-block-code"><code>import base64, hashlib, hmac

def verify(raw_body: bytes, header_hmac: str, secret: str) -&gt; bool:
    digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
    computed = base64.b64encode(digest).decode()
    return hmac.compare_digest(computed, header_hmac)</code></pre>



<p class="wp-block-paragraph">Shopify sends the signature in the <code>X-Shopify-Hmac-SHA256</code> header, base64-encoded, computed with your app&#8217;s client secret over the raw body. Store that secret in Secrets Manager or as an SSM SecureString parameter, not in a Lambda environment variable.</p>



<p class="wp-block-paragraph">Acknowledge fast. Shopify&#8217;s timeout is short and it retries with backoff over a finite window, so a handler that does real work inline will generate a wall of duplicate deliveries during a flash sale, exactly when you can least afford it. Return 2xx immediately, do the work asynchronously.</p>



<h3 class="wp-block-heading">Path 3: scheduled GraphQL bulk pull</h3>



<p class="wp-block-paragraph">Webhooks give you low latency. They do not give you completeness. Anything that fails past its retry window is gone, and Shopify will eventually remove a subscription that keeps failing. That&#8217;s a silent data loss mode with no local symptom at all.</p>



<p class="wp-block-paragraph">So run a scheduled reconciliation pull alongside the stream. Shopify&#8217;s GraphQL bulk operations are built for this: you submit a query, it runs asynchronously, and you fetch a JSONL result file when it finishes. That&#8217;s the right tool for backfills and nightly catch-up, rather than paginating thousands of pages against a points-based rate limiter and getting throttled halfway through.</p>



<p class="wp-block-paragraph">A nightly job that re-pulls the last seven to fourteen days and overwrites those partitions costs almost nothing and quietly fixes every category of drift described above. If you build one thing from this post, build that.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Decide what &#8220;revenue&#8221; means before you write a line of SQL</h2>



<p class="wp-block-paragraph">This is where most reconciliation arguments actually live, and it isn&#8217;t an engineering problem at all until you&#8217;ve had the conversation.</p>



<p class="wp-block-paragraph">Shopify&#8217;s own sales reporting builds total sales from gross sales, minus discounts, minus returns, plus taxes and shipping. Gift card sales sit outside that in a separate finance report. If your dashboard sums order totals and calls it revenue, you have built a different metric with the same name, and it will disagree with the admin forever no matter how good your pipeline is.</p>



<p class="wp-block-paragraph">Write the definition down. Put it in the dashboard as a tooltip. When someone challenges a number, you want the argument to be about the definition, not about whether your infrastructure works.</p>



<h3 class="wp-block-heading">The currency trap</h3>



<p class="wp-block-paragraph">If the store sells in more than one currency, the money fields split in two. Shopify exposes totals as a set containing both <code>shop_money</code> and <code>presentment_money</code>: the amount in the store&#8217;s base currency, and the amount the customer actually saw and paid.</p>



<p class="wp-block-paragraph">Sum the presentment amounts across a multi-currency store and you get a number with no meaning at all, euros and yen added together as if they were the same unit. For a single reporting figure you want the shop-currency side. Keep the presentment amount and its currency code in the table anyway, because the day someone asks &#8220;how much did we actually sell in Germany&#8221;, you&#8217;ll want it and it is painful to backfill.</p>



<p class="wp-block-paragraph">One caveat worth knowing: orders created through the API rather than through checkout can behave differently from native multi-currency checkout orders. If your store takes orders from an ERP or a marketplace integration, spot-check a few of those specifically.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Storage layout: partition by order date, never by arrival date</h2>



<p class="wp-block-paragraph">Two layers in S3. Keep them separate and keep them honest about what they are.</p>



<ol class="wp-block-list">
<li><strong>Raw.</strong> Every payload exactly as received, partitioned by ingestion date. Append-only, never edited. This is your audit trail and your rebuild source. Lifecycle it to a colder storage class after a few months, don&#8217;t delete it.</li>

<li><strong>Curated.</strong> One row per order representing current state, in Parquet, partitioned by <em>order date</em>. This is what the dashboard queries. It is derived, disposable and rewritable.</li>
</ol>



<p class="wp-block-paragraph">The partitioning choice on the curated layer is the load-bearing decision in the whole design. If you partition by arrival date, which is what Amazon Data Firehose does by default because it buckets on the moment it writes the file, then a refund that arrives three weeks late lands in today&#8217;s partition. Correcting Monday now means finding and rewriting fragments scattered across twenty other partitions. Partitioned by order date, correcting Monday means overwriting exactly one prefix.</p>



<p class="wp-block-paragraph">Firehose can do this with dynamic partitioning, which routes records by keys inside the payload rather than by write time. If you&#8217;re not using Firehose, extract the order date in your Lambda and write the prefix yourself.</p>



<h3 class="wp-block-heading">Use partition projection so Athena stops guessing</h3>



<p class="wp-block-paragraph">The default Glue Data Catalog approach means running a crawler or issuing <code>MSCK REPAIR TABLE</code> to register new partitions. Forget one and you get a query that silently returns nothing for recent days. Nobody notices until Monday.</p>



<p class="wp-block-paragraph">Partition projection removes the metastore lookup entirely. You tell Athena the shape of the partition keys and it calculates the prefixes at query time:</p>



<pre class="wp-block-code"><code>CREATE EXTERNAL TABLE shop_orders (
  order_id             bigint,
  order_number         string,
  created_at           timestamp,
  financial_status     string,
  cancelled_at         timestamp,
  is_test              boolean,
  total_shop           decimal(12,2),
  shop_currency        string,
  total_presentment    decimal(12,2),
  presentment_currency string
)
PARTITIONED BY (order_date string)
STORED AS PARQUET
LOCATION 's3://your-bucket/curated/orders/'
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.order_date.type' = 'date',
  'projection.order_date.format' = 'yyyy-MM-dd',
  'projection.order_date.range' = '2019-01-01,NOW',
  'projection.order_date.interval' = '1',
  'projection.order_date.interval.unit' = 'DAYS',
  'storage.location.template' =
    's3://your-bucket/curated/orders/order_date=${order_date}/'
);</code></pre>



<p class="wp-block-paragraph">Set the range start to your store&#8217;s actual first order month. Projection generates every prefix in the range, so a range starting a decade too early makes wide scans slower for no benefit.</p>



<p class="wp-block-paragraph">Parquet matters here for the same reason. Athena bills on bytes scanned, so a columnar format with good compression cuts the bill directly, and a dashboard that only ever selects six columns from a forty-column table never touches the rest.</p>



<h3 class="wp-block-heading">Net sales in one query</h3>



<p class="wp-block-paragraph">With refunds in their own table keyed by order and carrying their own date, attributing them back to the original order day is a left join and a subtraction:</p>



<pre class="wp-block-code"><code>SELECT
    o.order_date,
    SUM(o.total_shop)                                AS gross_shop,
    SUM(COALESCE(r.refunded_shop, 0))                AS refunded_shop,
    SUM(o.total_shop - COALESCE(r.refunded_shop, 0)) AS net_shop
FROM shop_orders o
LEFT JOIN (
    SELECT order_id, SUM(amount_shop) AS refunded_shop
    FROM shop_refunds
    GROUP BY order_id
) r ON r.order_id = o.order_id
WHERE o.order_date BETWEEN '2025-01-01' AND '2025-01-31'
  AND o.is_test = false
  AND o.cancelled_at IS NULL
GROUP BY o.order_date
ORDER BY o.order_date;</code></pre>



<p class="wp-block-paragraph">Note the two filters doing quiet work at the bottom. Those two lines are the difference between a number finance accepts and a number they don&#8217;t.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Choosing the dashboard layer</h2>



<p class="wp-block-paragraph">Once the data is correct, this part is genuinely a preference. All of these work.</p>



<ul class="wp-block-list">
<li><strong>Amazon QuickSight</strong>, now delivered as part of Amazon Quick Suite, is the least-friction option if you&#8217;re already in AWS. Its in-memory SPICE layer means viewers aren&#8217;t firing an Athena query per chart interaction, which controls both latency and scan cost. Per-viewer pricing tends to be the deciding factor either way, so model it for your actual audience size before committing.</li>

<li><strong>Grafana</strong> with the Athena data source is a good fit if you&#8217;re already running Grafana for infrastructure and want commercial and operational panels on one screen. Grafana Cloud removes the hosting question if you&#8217;d rather not run it.</li>

<li><strong>Power BI</strong> makes sense when the finance team already lives in Microsoft 365 and models in DAX. The cross-cloud hop is real but manageable.</li>

<li><strong>Metabase</strong> or a self-hosted alternative on a small VPS from a provider like InterServer or Hetzner is the pragmatic answer for a handful of internal viewers, where per-seat BI licensing costs more than the entire pipeline.</li>
</ul>



<p class="wp-block-paragraph">The honest trade-off: managed BI costs more per month and saves you from becoming the person who patches the reporting server. Self-hosting inverts that. Neither is wrong, but pick deliberately rather than by inertia.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: symptom to cause</h2>



<h3 class="wp-block-heading">Totals are consistently higher than the Shopify admin</h3>



<p class="wp-block-paragraph">Almost always refunds, cancellations or test orders. Check in that order. If the gap grows with the age of the reporting window, it&#8217;s refunds. If it&#8217;s a fixed offset on specific days, look for test orders or a QA run.</p>



<h3 class="wp-block-heading">Totals are lower, and recent days are missing rows</h3>



<p class="wp-block-paragraph">Either partitions aren&#8217;t registered, which projection fixes permanently, or the webhook subscription has been dropped after repeated delivery failures. Check the subscription still exists before you go digging through Lambda logs. A nightly bulk pull would have masked this, which is another argument for having one.</p>



<h3 class="wp-block-heading">Orders appear twice</h3>



<p class="wp-block-paragraph">Shopify&#8217;s delivery model is at-least-once, not exactly-once, and you may also have more than one subscription on the same topic. Deduplicate on the delivery ID header before you touch anything else, and make the write itself idempotent so a duplicate is a no-op rather than a second row.</p>



<h3 class="wp-block-heading">Numbers are right on the daily view, wrong on the monthly</h3>



<p class="wp-block-paragraph">Timezone. Order timestamps carry an offset; your partition key is a date string. If you derive the date in UTC and the store reports in a local timezone, orders near midnight land on the wrong day. That averages out over a month, which is exactly why the discrepancy hides until month boundaries.</p>



<h3 class="wp-block-heading">Athena costs jumped without more data</h3>



<p class="wp-block-paragraph">Someone built a dashboard with a filter that doesn&#8217;t hit the partition column, so every panel refresh scans the full table. Look at bytes scanned per query and check whether the BI tool is caching results or re-querying on every interaction.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating orders as immutable events and never revisiting a past day.</li>

<li>Partitioning on arrival time because that&#8217;s the default, then discovering corrections are expensive.</li>

<li>Verifying the HMAC against a re-serialised body instead of the raw bytes.</li>

<li>Doing real work inside the webhook handler, generating duplicates under load.</li>

<li>Summing presentment amounts across currencies.</li>

<li>Registering the event bus ARN with Shopify instead of the partner event source ARN.</li>

<li>Relying on webhooks alone with no scheduled reconciliation.</li>

<li>Shipping a &#8220;revenue&#8221; number without ever defining what it includes.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep raw and curated layers separate. Raw is append-only; curated is rewritable.</li>

<li>Make every partition idempotently rebuildable from raw. Test that path deliberately, before you need it.</li>

<li>Buffer through SQS with a dead-letter queue. Free replay, free isolation of bad payloads.</li>

<li>Run a nightly bulk pull over a rolling window and overwrite those partitions.</li>

<li>Use partition projection. It removes an entire category of silent failure.</li>

<li>Alarm on the absence of events, not just on errors. A CloudWatch alarm on zero orders processed in an hour during business hours catches broken subscriptions the same day.</li>

<li>Store the API secret in Secrets Manager and scope the Lambda role to the exact prefixes it writes.</li>

<li>Publish a reconciliation panel comparing your total to the admin&#8217;s for the same window. Surfacing the gap builds more trust than hiding it.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I need a data warehouse, or is S3 and Athena enough?</h3>



<p class="wp-block-paragraph">For a single store&#8217;s order data, S3 with Athena is almost certainly enough, and it&#8217;s cheaper because you pay per query rather than for a running cluster. Redshift starts to earn its place when you&#8217;re joining Shopify data against several other large sources, or when concurrent query load makes Athena&#8217;s queue times noticeable.</p>



<h3 class="wp-block-heading">How near-real-time can this be?</h3>



<p class="wp-block-paragraph">Events land within seconds. The practical floor is your buffering window, since writing one tiny file per order gives you a small-files problem that ruins query performance. A few minutes of buffering is the usual compromise. If you genuinely need sub-minute order counts, put a live counter in DynamoDB alongside the analytical pipeline rather than trying to make the data lake do both jobs.</p>



<h3 class="wp-block-heading">Which webhook topics should I subscribe to?</h3>



<p class="wp-block-paragraph">At minimum, order creation, order update, order cancellation and refund creation. Update and refund topics are the ones people skip, and they&#8217;re exactly the ones carrying the corrections. Subscribe to fewer topics than you think you need and add rather than subscribing to everything, since every extra topic is volume you pay to store and process.</p>



<h3 class="wp-block-heading">Can I skip AWS and use a connector tool?</h3>



<p class="wp-block-paragraph">Yes, and for many stores that&#8217;s the right answer. A managed connector into a hosted warehouse gets you a working dashboard in an afternoon. You&#8217;re paying a monthly fee to avoid owning any of this, and trading away control over the data model. Building it on AWS wins when you need Shopify data joined to systems the connector doesn&#8217;t cover, or when row-based connector pricing outgrows the infrastructure cost.</p>



<h3 class="wp-block-heading">How do I backfill historical orders?</h3>



<p class="wp-block-paragraph">Use a GraphQL bulk operation rather than paginating the API. Submit the query, poll for completion, then stream the JSONL result into your raw bucket and run the same transformation your live pipeline uses. If backfill and live processing use different code paths, they will diverge, and you&#8217;ll spend an afternoon working out which one is lying.</p>



<h3 class="wp-block-heading">What does a setup like this cost to run?</h3>



<p class="wp-block-paragraph">For a typical single store, the pipeline itself is small money: Lambda invocations, a few gigabytes in S3, and Athena billed on bytes scanned, which partitioning and Parquet keep low. The BI seats are usually the largest line item, which is why the dashboard layer decision deserves more thought than the ingestion one. Model it against current published rates rather than trusting any figure you read in a blog post, including this one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">A Shopify sales dashboard with AWS doesn&#8217;t fail because the pipeline breaks. It fails because the pipeline keeps working perfectly on data that has since changed underneath it.</p>



<p class="wp-block-paragraph">Design for correction from the first commit. Partition by order date, keep the raw layer so you can always rebuild, run a scheduled pull to catch what the stream missed, and agree on what revenue means before anyone builds a chart. Do that and the Monday morning screenshot comparison becomes a non-event, which is the highest praise a reporting pipeline ever gets.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help with your Shopify data pipeline on AWS?</h2>



<p class="wp-block-paragraph">I design and build ecommerce data pipelines and reporting stacks on AWS. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Working out why an existing Shopify dashboard disagrees with the admin, and fixing the root cause rather than patching the query</li>

<li>Building the ingestion layer end to end: EventBridge or API Gateway, Lambda, SQS with dead-letter handling, and a scheduled GraphQL bulk reconciliation job</li>

<li>Designing the S3 layout, Glue schema and Athena tables so past days can be recomputed cheaply and partitions never go missing</li>

<li>Migrating REST Admin API integrations to GraphQL before the deadline forces the issue</li>

<li>Building the dashboard itself in QuickSight, Grafana or Metabase, including the metric definitions finance will actually sign off on</li>

<li>Cutting Athena scan costs and BI licensing on a reporting stack that has grown more expensive than anyone planned</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;re in the middle of one of these, send me the actual thing: the Athena query, the S3 prefix layout, the two totals that don&#8217;t match. It&#8217;s a much faster conversation than describing it in the abstract.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/shopify-sales-dashboard-aws/">Shopify Sales Dashboard with AWS: Build One That Actually Reconciles</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/technical-guides/shopify-sales-dashboard-aws/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Building a Jira Analytics Pipeline with AWS Lambda and Athena (Without Double-Counting Everything)</title>
		<link>https://john-nessime.com/blog/devops/jira-analytics-pipeline-aws-lambda-athena/</link>
					<comments>https://john-nessime.com/blog/devops/jira-analytics-pipeline-aws-lambda-athena/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Glue]]></category>
		<category><![CDATA[AWS Lambda]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Engineering Metrics]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Jira]]></category>
		<category><![CDATA[Partition Projection]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Serverless]]></category>
		<category><![CDATA[SQL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=125</guid>

					<description><![CDATA[<p>Jira's built-in reports stop at the board boundary. This guide walks through a Jira analytics pipeline built on AWS Lambda, S3 and Athena, organised around the four failure families that actually bite: the removed search endpoint, silently truncated changelogs, incremental loads that duplicate rows, and an S3 layout that quietly inflates your query bill.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/jira-analytics-pipeline-aws-lambda-athena/">Building a Jira Analytics Pipeline with AWS Lambda and Athena (Without Double-Counting Everything)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone in a delivery review asks why cycle time went up last quarter. You open the Jira dashboard, and it can tell you what is in progress right now, roughly. It cannot tell you what &#8220;in progress&#8221; meant three months ago, how long each ticket sat in code review, or whether the increase came from one team or all six. The control chart resets when the board configuration changes, the sprint report only knows about sprints, and the CSV export tops out somewhere unhelpful.</p>



<p class="wp-block-paragraph">That is usually the moment someone says &#8220;let&#8217;s just pull it into a warehouse.&#8221; A <strong>Jira analytics pipeline</strong> built on AWS Lambda, S3 and Athena is a reasonable answer to that, and it is genuinely cheap to run. It is also easy to build a version that looks correct for two weeks and then quietly reports numbers that are thirty percent wrong.</p>



<p class="wp-block-paragraph">This post covers the extraction and modelling problems that actually cost you time: the search endpoint Atlassian removed, the change history that truncates without erroring, the incremental load pattern that duplicates rows across partitions, and the S3 layout decisions that decide whether Athena costs you pennies or hundreds. Code where it clarifies something, and honest notes on what I would skip.</p>



<h2 class="wp-block-heading">Where Jira&#8217;s own reporting genuinely stops</h2>



<p class="wp-block-paragraph">Give the built-in tooling its due first. Jira&#8217;s velocity, burndown and control charts are fine for a single team inspecting its own recent work, they need no infrastructure, and they update instantly. Marketplace apps like eazyBI and Custom Charts cover a lot of ground without you writing a line of Python. If your question is &#8220;how did this sprint go,&#8221; you do not need a pipeline.</p>



<p class="wp-block-paragraph">Where it stops is anything that crosses a boundary. Comparing lead time across projects that use different workflows. Joining ticket data to deploy events from your CI system or incident data from PagerDuty. Retaining a consistent view of history after someone renames a status or archives a board. Answering a question nobody anticipated when the board was configured. Those need the raw data somewhere you control, in a shape you decide.</p>



<h2 class="wp-block-heading">The shape of the pipeline</h2>



<p class="wp-block-paragraph">The architecture is unremarkable, which is the point:</p>



<ol class="wp-block-list">
<li>EventBridge Scheduler triggers a Lambda function on a schedule.</li>

<li>Lambda reads a Jira API token from Secrets Manager and pages through the Jira Cloud REST API.</li>

<li>It writes Parquet files to S3, partitioned by load date.</li>

<li>The Glue Data Catalog holds the table definitions, with partition projection so nothing has to crawl.</li>

<li>Athena queries S3 directly. Grafana, Power BI, Metabase or QuickSight sit on top of Athena.</li>
</ol>



<p class="wp-block-paragraph">No cluster, no always-on database, nothing to patch. The whole thing costs about as much as a small EC2 instance for a mid-sized Jira site, and most of that is S3 storage. Terraform or CloudFormation to define it, GitHub Actions to deploy it.</p>



<p class="wp-block-paragraph">The complexity is not in the wiring. It is in four places, and they are worth taking in order.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family 1: the search endpoint you were probably going to use is gone</h2>



<p class="wp-block-paragraph">Almost every Jira extraction tutorial and a good number of client libraries still reach for <code>GET /rest/api/3/search</code>. Atlassian removed it from Jira Cloud. It returns 410 Gone. If you are copying a script from a blog post that predates the change, this is the first thing that breaks, and at least it breaks loudly.</p>



<p class="wp-block-paragraph">The replacement is <code>/rest/api/3/search/jql</code>, available as both GET and POST. Use POST for anything real, because JQL strings get long and you avoid URL encoding entirely. Three behavioural changes matter more than the URL:</p>



<h3 class="wp-block-heading">Pagination is cursor-based, and there is no total</h3>



<p class="wp-block-paragraph"><code>startAt</code> is gone. You get a <code>nextPageToken</code> back and hand it to the next request. There is no <code>total</code> in the response, which means any progress bar, any &#8220;expected N rows&#8221; sanity check, and any loop that terminated on <code>startAt &gt;= total</code> needs rewriting.</p>



<p class="wp-block-paragraph">If you only need a count, there is a separate operation, <code>POST /rest/api/3/search/approximate-count</code>, which takes a JQL body and returns an approximate figure without paging through results. It is genuinely useful as a reconciliation check: run it before extraction, compare against the row count you actually wrote, and alert on a large gap.</p>



<p class="wp-block-paragraph">There have been persistent community reports of <code>isLast</code> behaving unreliably on this endpoint, including tokens that chain without ever terminating. Do not trust <code>isLast</code> as your loop condition. Terminate on the absence of <code>nextPageToken</code>, and put a hard page cap in as a circuit breaker so a bad token cannot burn your entire Lambda budget in one invocation.</p>



<pre class="wp-block-code"><code>def search_issues(session, base_url, jql, fields, max_pages=2000):
    """Page through /search/jql. Terminates on missing nextPageToken,
    not on isLast, which has been reported as unreliable."""
    token = None
    for _ in range(max_pages):
        body = {"jql": jql, "fields": fields, "maxResults": 100}
        if token:
            body["nextPageToken"] = token
        r = session.post(f"{base_url}/rest/api/3/search/jql",
                         json=body, timeout=60)
        r.raise_for_status()
        page = r.json()
        for issue in page.get("issues", []):
            yield issue
        token = page.get("nextPageToken")
        if not token:
            return
    raise RuntimeError("page cap hit, refusing to loop further")</code></pre>



<h3 class="wp-block-heading">You have to ask for fields explicitly</h3>



<p class="wp-block-paragraph">The new endpoint does not hand you every field by default. Omit <code>fields</code> and you get essentially nothing back. This is the failure that looks like success: the pipeline runs, files land in S3, row counts look plausible, and every analytical column is null.</p>



<p class="wp-block-paragraph">Be explicit and be narrow. Every field you request costs response size, and Jira sites accumulate hundreds of custom fields nobody uses. Name what you need:</p>



<pre class="wp-block-code"><code>FIELDS = [
    "summary", "status", "issuetype", "project", "priority",
    "assignee", "reporter", "created", "updated", "resolutiondate",
    "labels", "components", "parent",
    "customfield_10016",   # story points on this site, verify yours
]</code></pre>



<p class="wp-block-paragraph">Custom field IDs are per-site. Do not hardcode one you read in someone else&#8217;s blog post. Pull <code>/rest/api/3/field</code> once, find the field by name, and either store the mapping in config or resolve it at runtime and log what it resolved to. When a Jira admin rebuilds a field, an ID-based pipeline goes null and a name-resolving pipeline keeps working.</p>



<h3 class="wp-block-heading">Rate limits and the fifteen-minute wall</h3>



<p class="wp-block-paragraph">Jira Cloud applies cost-based rate limiting and returns HTTP 429 when you exceed it, typically with a <code>Retry-After</code> header. Respect that header rather than inventing your own backoff. A naive retry loop that ignores it turns a brief throttle into a sustained one.</p>



<p class="wp-block-paragraph">Lambda&#8217;s hard ceiling is fifteen minutes. A full historical backfill of a large Jira site will not finish in one invocation, and the ugly failure mode is a function that times out at minute fifteen having written half its data with no record of where it stopped. Two ways out:</p>



<ul class="wp-block-list">
<li><strong>Shard the work.</strong> Fan out one Lambda invocation per project key, or per month of created date. Each one is small, independently retryable, and finishes well inside the limit.</li>

<li><strong>Checkpoint and continue.</strong> Persist the current <code>nextPageToken</code> to DynamoDB or S3 after each page. When the function is close to its deadline, stop cleanly and let Step Functions re-invoke it from the checkpoint.</li>
</ul>



<p class="wp-block-paragraph">Sharding is simpler and I reach for it first. Checkpointing is what you need when a single project is itself too large. Either way, watch Lambda&#8217;s ephemeral storage: the default <code>/tmp</code> allocation is 512 MB, and buffering a large Parquet write there will fail before your API calls do. Raise it or stream to S3 in chunks.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family 2: the change history is the whole point, and it truncates silently</h2>



<p class="wp-block-paragraph">Current issue state answers almost none of the interesting questions. How long a ticket spent waiting for review, how many times it bounced back from QA, when it actually entered development rather than when someone remembered to drag the card, all of that lives in the changelog. Without it you have a list of tickets. With it you have a process.</p>



<p class="wp-block-paragraph">Here is the trap. Requesting an issue with <code>expand=changelog</code> returns a capped number of history entries, commonly the first hundred, and it does not tell you it truncated. Well-worn tickets with lots of field edits blow past that easily. Your data does not error, it just quietly loses the later transitions, which are usually the ones near completion. Cycle time comes out looking better than reality.</p>



<p class="wp-block-paragraph">Two correct approaches:</p>



<ul class="wp-block-list">
<li><code>GET /rest/api/3/issue/{issueIdOrKey}/changelog</code> and page it properly. Correct, but it is one request per issue, which is brutal against rate limits on a large site.</li>

<li><code>POST /rest/api/3/changelog/bulkfetch</code>, which accepts <code>issueIdsOrKeys</code> and an optional <code>fieldIds</code> filter, with the same <code>nextPageToken</code> pagination. Far fewer round trips.</li>
</ul>



<p class="wp-block-paragraph">Bulk fetch is the one I would use, with a caveat: it has carried an experimental designation, so pin your expectations and keep the per-issue path available as a fallback. Filter <code>fieldIds</code> to <code>status</code> if status history is all you model. That cuts the response size enormously, because most changelog volume is description edits and label churn nobody analyses.</p>



<p class="wp-block-paragraph">Store changelog as its own narrow table, one row per field change, not nested inside the issue record. Athena can handle nested structures, but flat is dramatically easier to reason about in SQL and much cheaper to scan.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family 3: the incremental load that duplicates everything</h2>



<p class="wp-block-paragraph">This is the one that bites hardest, because nothing fails. Everything runs green for weeks and the numbers are wrong the entire time.</p>



<p class="wp-block-paragraph">The obvious incremental design is a watermark: track the last successful run, then pull <code>updated &gt;= watermark</code> on each run and append the results to S3. It is the standard pattern and it works for immutable event data.</p>



<p class="wp-block-paragraph">Jira issues are not immutable. A ticket created in January and touched every week appears in every weekly extract. If you append each run into its own partition, that one issue now exists as a row in twenty partitions. Athena is doing exactly what you asked: <code>SELECT count(*) FROM jira_issues WHERE status = 'Open'</code> counts twenty things. Your open bug count is inflated, your throughput is inflated, and the inflation is proportional to how much a ticket gets edited, which correlates with how contentious it was. The busiest work is the most over-counted.</p>



<p class="wp-block-paragraph">Nobody catches this from the dashboard, because the numbers move in the right direction and look approximately sane. It surfaces months later when someone reconciles against a JQL query in Jira and the two disagree.</p>



<h3 class="wp-block-heading">Pick one of three fixes, deliberately</h3>



<p class="wp-block-paragraph"><strong>Full snapshot per load, dedupe at read time.</strong> Keep every version, partition by <code>load_date</code>, and always read through a view that takes the latest row per issue key. Storage is cheap, history is free, and you can answer &#8220;what did the board look like in March&#8221; without any extra machinery. The cost is that every query pays for the deduplication.</p>



<pre class="wp-block-code"><code>CREATE OR REPLACE VIEW jira_issues_current AS
SELECT * FROM (
  SELECT
    i.*,
    ROW_NUMBER() OVER (
      PARTITION BY issue_key
      ORDER BY load_date DESC, updated DESC
    ) AS rn
  FROM jira_issue_snapshot i
  WHERE load_date &gt;= date_format(current_date - interval '7' day, '%Y-%m-%d')
) WHERE rn = 1;</code></pre>



<p class="wp-block-paragraph">The <code>load_date</code> filter inside the view matters. Without it the deduplication window scans the entire table on every query, which is the single most common way a cheap Athena setup becomes an expensive one.</p>



<p class="wp-block-paragraph"><strong>Overwrite the affected partitions.</strong> Partition by something stable, usually issue created month, and rewrite whole partitions when any issue in them changes. Clean reads, no dedupe cost, but you now own read-modify-write logic in Lambda and a concurrency problem if two runs overlap.</p>



<p class="wp-block-paragraph"><strong>Use an ACID table format.</strong> Apache Iceberg gives you real <code>MERGE INTO</code> semantics on S3, and Athena supports it natively. This is the right answer if you are already running Iceberg elsewhere or if the pipeline will grow to a dozen sources. It is not worth adopting solely to load one Jira site, because you inherit compaction and snapshot expiry as ongoing maintenance.</p>



<p class="wp-block-paragraph">For a single Jira site feeding a handful of dashboards, snapshot plus a dedupe view is what I would build. It has the fewest moving parts and it gives you point-in-time history as a side effect, which you will want the first time someone asks a retrospective question.</p>



<h3 class="wp-block-heading">Deletions and moves</h3>



<p class="wp-block-paragraph">A JQL watermark query never returns deleted issues, so they persist in your data forever. Same for issues moved out of scope or into an archived project. Periodically reconcile: pull the full set of issue keys with a minimal <code>fields</code> list, compare against what you hold, and mark the difference. Monthly is usually enough. Skip this and your historical counts drift upward permanently.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family 4: the S3 layout that decides your Athena bill</h2>



<p class="wp-block-paragraph">Athena&#8217;s standard pricing model bills on bytes scanned, rounded up, with a small per-query minimum. DDL statements are free. That means your storage layout, not your SQL, is what determines cost.</p>



<p class="wp-block-paragraph">Three things do almost all the work:</p>



<ul class="wp-block-list">
<li><strong>Write Parquet, not JSON.</strong> Columnar storage lets Athena read only the columns your query touches. A dashboard selecting five columns from a forty-column table scans a small fraction of what the equivalent JSON would. Snappy or Zstd compression on top of that.</li>

<li><strong>Avoid tiny files.</strong> A Lambda that runs hourly and writes one small file per invocation produces thousands of objects. Athena spends more time opening files than reading them. Batch writes so files land in the low hundreds of megabytes, or run a periodic compaction job.</li>

<li><strong>Use partition projection.</strong> This is the one people skip and then wonder why queries have a fixed few-second overhead.</li>
</ul>



<p class="wp-block-paragraph">Partition projection lets Athena calculate partition locations from table properties instead of calling <code>GetPartitions</code> against the Glue Data Catalog. On a table with a couple of years of daily partitions, that lookup is real latency on every single query, and it grows as the table does. Projection removes it, and removes your need for a Glue crawler entirely, which is one less scheduled job and one less IAM role.</p>



<pre class="wp-block-code"><code>CREATE EXTERNAL TABLE jira_issue_snapshot (
  issue_id        string,
  issue_key       string,
  project_key     string,
  issue_type      string,
  status          string,
  status_category string,
  assignee_id     string,
  created         timestamp,
  updated         timestamp,
  resolutiondate  timestamp,
  story_points    double
)
PARTITIONED BY (load_date string)
STORED AS PARQUET
LOCATION 's3://example-jira-lake/issue_snapshot/'
TBLPROPERTIES (
  'projection.enabled'                = 'true',
  'projection.load_date.type'         = 'date',
  'projection.load_date.format'       = 'yyyy-MM-dd',
  'projection.load_date.range'        = 'NOW-3YEARS,NOW',
  'projection.load_date.interval'     = '1',
  'projection.load_date.interval.unit'= 'DAYS',
  'storage.location.template'         =
    's3://example-jira-lake/issue_snapshot/load_date=${load_date}'
);</code></pre>



<p class="wp-block-paragraph">One caution that surprises people: projection describes partitions Athena will look for, not partitions that exist. Set a range wider than your data and queries without a <code>load_date</code> filter will probe empty prefixes. Keep the range tight to what you actually hold.</p>



<p class="wp-block-paragraph">Finally, set <code>BytesScannedCutoffPerQuery</code> on the Athena workgroup. It kills any query that exceeds a scan threshold. One analyst running <code>SELECT *</code> against three years of data in a BI tool&#8217;s preview pane is the classic surprise line item, and this stops it at the source.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Modelling time in status</h2>



<p class="wp-block-paragraph">Once status changelog rows are landed flat, the core metric is a window function. Each transition&#8217;s duration is the gap to the next transition on the same issue:</p>



<pre class="wp-block-code"><code>WITH transitions AS (
  SELECT
    issue_key,
    to_status,
    changed_at,
    LEAD(changed_at) OVER (
      PARTITION BY issue_key ORDER BY changed_at
    ) AS next_changed_at
  FROM jira_changelog
  WHERE field_id = 'status'
    AND load_date &gt;= date_format(current_date - interval '90' day, '%Y-%m-%d')
)
SELECT
  issue_key,
  to_status,
  SUM(date_diff('second', changed_at,
                COALESCE(next_changed_at, current_timestamp))) / 3600.0
    AS hours_in_status
FROM transitions
GROUP BY issue_key, to_status;</code></pre>



<p class="wp-block-paragraph">The <code>COALESCE</code> handles the current status, which has no successor transition. Two modelling decisions to make consciously: whether to subtract non-working hours, and how to treat a ticket that moves backwards through the workflow. Both are business questions, not technical ones, and both should be settled in a documented view rather than reimplemented in each dashboard.</p>



<p class="wp-block-paragraph">Map raw status names to a stable category early. Teams rename statuses constantly, and a metric keyed on the literal string breaks the moment someone changes &#8220;In Review&#8221; to &#8220;Peer Review&#8221;. A small mapping table joined at query time keeps history comparable.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong>Every analytical column is null.</strong> You did not pass <code>fields</code>, or you passed a custom field ID from another site. Log the resolved field list on every run.</li>

<li><strong>Counts higher than the same JQL in Jira.</strong> Duplicate rows across partitions. Check whether your query goes through the dedupe view or straight at the base table.</li>

<li><strong>Extraction loops forever.</strong> Do not terminate on <code>isLast</code>. Terminate on missing <code>nextPageToken</code> and keep a page cap.</li>

<li><strong>HTTP 410 from the API.</strong> You are still calling the removed <code>/rest/api/3/search</code>. Check your client library version too, not just your own code.</li>

<li><strong>Cycle times suspiciously low.</strong> Truncated changelog. Move to the dedicated changelog endpoint or bulk fetch.</li>

<li><strong>Athena returns zero rows but the files are there.</strong> Partition projection range does not cover the partition, or the S3 prefix does not match <code>storage.location.template</code>. Compare a real object key against the template character by character.</li>

<li><strong>Queries slow down as the table grows.</strong> Either you have no projection and Glue lookups dominate, or you have accumulated small files.</li>

<li><strong>Authentication failures overnight with no deploy.</strong> API token expired or was revoked. Alert on the specific status code rather than on &#8220;run failed&#8221;.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Appending incremental extracts without a deduplication strategy.</li>

<li>Building on current issue state and adding changelog later. Retrofitting history is far more work than including it from the start.</li>

<li>Hardcoding custom field IDs copied from documentation.</li>

<li>Storing the API token in a Lambda environment variable instead of Secrets Manager.</li>

<li>Running an hourly schedule when the dashboard is read once a day. You pay in small files and API quota for freshness nobody uses.</li>

<li>Letting BI tools query base tables directly instead of curated views.</li>

<li>Never reconciling against Jira. A scheduled check comparing approximate count to your row count catches drift within a day.</li>
</ul>



<h2 class="wp-block-heading">Best practices for a Jira analytics pipeline that survives</h2>



<ul class="wp-block-list">
<li>Land raw API responses to S3 before transforming. When your parsing is wrong, and it will be, you replay from raw rather than re-hammering the API.</li>

<li>Make the load idempotent. Re-running for the same date should produce the same result, not a second copy.</li>

<li>Define the pipeline in Terraform or CloudFormation and deploy it from CI. The IAM policy for Lambda and Athena is fiddly and you do not want to rebuild it from memory.</li>

<li>Alert on a run that succeeds with zero rows, not just on runs that error. Silent empty loads are the more common failure.</li>

<li>Scope the Jira token to a service account with read access to exactly the projects you need.</li>

<li>Put an S3 lifecycle policy on the raw zone. It grows faster than you expect and nobody queries last year&#8217;s raw JSON.</li>

<li>Expose curated views, not tables, to Grafana, Metabase, Power BI or QuickSight, and enable Athena&#8217;s query result reuse for repeated dashboard loads.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Should I use Lambda or Glue for Jira extraction?</h3>



<p class="wp-block-paragraph">Lambda, for an API-driven pull. The work is mostly waiting on HTTP responses, which Spark&#8217;s distributed compute does nothing for, and you would be paying for a Glue job&#8217;s minimum billing on something that is idle. Glue earns its place downstream, if you have heavy joins across several sources. Athena CTAS often covers that too.</p>



<h3 class="wp-block-heading">How often should the pipeline run?</h3>



<p class="wp-block-paragraph">Match the decision cadence, not the data cadence. Delivery metrics are reviewed weekly or in sprint ceremonies, so daily is almost always enough and produces far better file sizes. Reserve hourly for something with a real-time consumer, and know that you are buying that freshness with small-file overhead and API quota.</p>



<h3 class="wp-block-heading">Can I use webhooks instead of polling?</h3>



<p class="wp-block-paragraph">You can, and for near-real-time reaction it is the right tool. For analytics it is a poor primary source, because a missed delivery leaves a permanent hole you have no way to detect. The pattern that works is webhooks for freshness plus a scheduled reconciliation pull as the source of truth. If you only build one, build the scheduled pull.</p>



<h3 class="wp-block-heading">Does this work with Jira Data Center or Server?</h3>



<p class="wp-block-paragraph">The AWS half is identical. The extraction half is not. Cursor pagination and the <code>/search/jql</code> endpoint are Cloud-only changes, so self-hosted instances still use the older offset-based <code>/rest/api/2/search</code>. Write the extractor behind an interface if you need to support both, and expect the auth model to differ as well.</p>



<h3 class="wp-block-heading">How much does an Athena-based Jira pipeline cost to run?</h3>



<p class="wp-block-paragraph">The mechanism matters more than any figure I could quote. You pay for S3 storage, Lambda invocation time, and Athena per byte scanned with a small per-query minimum. Jira issue data compresses extremely well as Parquet, so storage stays small. The variable is dashboard query volume, which is why partitioning and workgroup scan limits matter. Model it against current AWS rates and your own expected query count.</p>



<h3 class="wp-block-heading">Why not just use eazyBI or a Jira reporting app?</h3>



<p class="wp-block-paragraph">Often you should. If your questions stay inside Jira, a Marketplace app gets you there in an afternoon with no infrastructure. The case for a pipeline is joining Jira to data that lives elsewhere, retaining history the app does not, or needing your data in a warehouse you already run. Build the pipeline when the app has actually failed you, not in anticipation.</p>



<h3 class="wp-block-heading">Should I load into Redshift instead of querying S3?</h3>



<p class="wp-block-paragraph">Only if you have concurrent BI users hitting the same tables constantly and Athena&#8217;s per-query latency is a real complaint. Jira data volumes are small, the query pattern is bursty, and Athena&#8217;s zero-idle-cost model fits that far better. Redshift makes sense as a consolidation layer across many sources, not for one issue tracker.</p>



<h2 class="wp-block-heading">The one thing to carry away</h2>



<p class="wp-block-paragraph">The hard part of a Jira analytics pipeline is not the AWS wiring. Lambda, S3, Glue and Athena will be working within a day. The hard part is that Jira issues are mutable, so an incremental load that appends is an incremental load that duplicates, and it does so without a single error in your logs.</p>



<p class="wp-block-paragraph">Decide your deduplication strategy before you write the first extract, not after someone reconciles a dashboard against JQL and finds a gap. Snapshot with a read-time dedupe view, partition projection so queries stay fast as history accumulates, and a scheduled reconciliation check that alerts on drift. Get those three right and the rest is plumbing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need this built or fixed?</h2>



<p class="wp-block-paragraph">I design and run serverless data pipelines on AWS, and Jira extraction has more sharp edges than most sources. Things I can help with directly:</p>



<ul class="wp-block-list">
<li>Migrating an extractor off the removed <code>/rest/api/3/search</code> endpoint to cursor-based <code>/search/jql</code> without losing rows in the cutover</li>

<li>Auditing an existing Jira pipeline for duplicate rows and reconciling your numbers back against JQL</li>

<li>Building changelog-based cycle time and time-in-status models that survive workflow renames</li>

<li>Restructuring an S3 layer with Parquet, partition projection and file compaction to cut Athena scan costs</li>

<li>Packaging the whole thing as Terraform or CloudFormation with CI deployment and least-privilege IAM</li>

<li>Connecting Athena to Grafana, Power BI, Metabase or QuickSight with curated views instead of raw tables</li>
</ul>



<p class="wp-block-paragraph">Send me your extractor code, a Glue table definition, or an Athena query that is scanning more than it should, and I will tell you what I would change.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/jira-analytics-pipeline-aws-lambda-athena/">Building a Jira Analytics Pipeline with AWS Lambda and Athena (Without Double-Counting Everything)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/jira-analytics-pipeline-aws-lambda-athena/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Redshift Cost Optimization for SaaS Analytics: The Levers That Actually Move the Bill</title>
		<link>https://john-nessime.com/blog/devops/redshift-cost-optimization-saas-analytics/</link>
					<comments>https://john-nessime.com/blog/devops/redshift-cost-optimization-saas-analytics/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 09:18:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Redshift]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[Embedded Analytics]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Multi-Tenant]]></category>
		<category><![CDATA[Row-Level Security]]></category>
		<category><![CDATA[SQL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=123</guid>

					<description><![CDATA[<p>In a SaaS analytics product, the Redshift bill tracks how often queries arrive, not how much data they touch. Here is how the meter actually works, why connection pools bill you while nobody is using the product, how to attribute spend to a tenant, and which isolation choices quietly cost more than they save.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/redshift-cost-optimization-saas-analytics/">Redshift Cost Optimization for SaaS Analytics: The Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message usually comes from whoever owns the AWS bill, and it is never dramatic. &#8220;Redshift is up again this month. Did we onboard someone big?&#8221; Nobody onboarded anyone. Nobody shipped a new dashboard. Query volume looks flat on the Grafana board. The bill moved anyway.</p>



<p class="wp-block-paragraph">That gap between what you think you are paying for and what you are actually paying for is what makes Redshift cost optimization awkward in a SaaS analytics product. You are not running one nightly batch against a warehouse that sleeps the rest of the day. You are serving hundreds of small, latency-sensitive queries that fire whenever a customer opens a dashboard, plus ingestion, plus whatever your BI layer and your connection pool are doing when nobody is watching.</p>



<p class="wp-block-paragraph">This post covers the levers that genuinely move that number: how the meter works, why idle-looking connections still bill, how to work out which tenant is expensive, and which isolation choices cost more than they save. Where the popular advice is wrong for SaaS specifically, I will say so.</p>



<h2 class="wp-block-heading">How Amazon Redshift actually charges you</h2>



<p class="wp-block-paragraph">Three buckets, and they behave very differently.</p>



<ul class="wp-block-list"><li><strong>Compute.</strong> On Redshift Serverless this is RPU-hours, metered per second. On provisioned clusters it is node-hours, plus separate line items for concurrency scaling and Spectrum.</li><li><strong>Storage.</strong> Redshift Managed Storage, billed by GB per month, independent of compute. Snapshots are storage too.</li><li><strong>Everything else.</strong> Cross-region data sharing and snapshot replication, machine learning, data transfer outside the usual in-region S3 paths.</li></ul>



<p class="wp-block-paragraph">In a SaaS analytics workload compute dominates, often overwhelmingly. And the important part: compute is a function of how long the warehouse is awake and at what capacity, not how many rows you touched. Two teams can scan identical data volumes and get bills that differ by a factor of five, purely because of how their queries arrive.</p>



<h2 class="wp-block-heading">The billing mechanic that catches SaaS teams out</h2>



<p class="wp-block-paragraph">Read the serverless billing notes properly once and a lot of mysterious spend stops being mysterious. The parts that matter:</p>



<ul class="wp-block-list"><li>The minimum charge is 60 seconds of resource usage, metered per second beyond that. This is a minimum for the warehouse, not for each individual query.</li><li>Usage is recorded when a transaction <em>completes</em>, rolls back, or is stopped. A transaction that runs for hours shows up in your usage view only at the end.</li><li>Cancel a query before it finishes and you still pay for the time it ran.</li><li>Querying system tables is billed like any other query. Your monitoring loop is a workload.</li><li>After a burst, capacity can stay elevated for a period after the load drops. Scale-down is not instant.</li></ul>



<p class="wp-block-paragraph">Put those together and you reach a conclusion that irritates most engineers: on serverless, ten small queries crammed into one minute are cheaper than the same ten queries spread across ten minutes. Every wake-up costs you a minimum billing window multiplied by your base capacity. That is the opposite of the instinct you have from tuning an OLTP service, where you smooth load out to protect tail latency.</p>



<p class="wp-block-paragraph">Before you change anything, get the real numbers out of the warehouse rather than out of Cost Explorer, which lags and aggregates.</p>



<pre class="wp-block-code"><code>-- Daily billed RPU-seconds converted to RPU-hours.
-- Multiply by your region's on-demand RPU-hour rate for dollars.
SELECT trunc(start_time) AS day,
       sum(charged_seconds) / 3600::double precision AS rpu_hours
FROM   sys_serverless_usage
GROUP  BY 1
ORDER  BY 1 DESC;</code></pre>



<p class="wp-block-paragraph"><code>charged_seconds</code> is the column to build cost reporting on. <code>compute_seconds</code> is informative but it is not what the invoice is derived from, and the two can disagree within a given interval. Two constraints worth knowing before you wire this into a dashboard: the view holds roughly a week of history, and it is visible only to superusers. If you want month-over-month trends, UNLOAD it to S3 on a schedule and query the archive with Amazon Athena instead.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Lever one: connections that look idle and are not</h2>



<p class="wp-block-paragraph">This is the one that bites hardest and shows up last, because there is nothing to see. AWS documents it plainly: Redshift Serverless treats all incoming queries as billable user activity, including lightweight health-check queries sent by connection pools. It does not matter whether the statement came from your application, a JDBC driver, or a pooling framework doing its job.</p>



<p class="wp-block-paragraph">So a pool that fires <code>SELECT 1</code> every thirty seconds to validate connections is a warehouse that never gets to sleep. Your product has no users at 3am and you are still paying the minimum window, over and over, multiplied by base capacity. HikariCP, Apache Commons DBCP and PgBouncer all have some form of this behaviour, and the defaults are tuned for OLTP databases where a validation query costs nothing.</p>



<p class="wp-block-paragraph">Open transactions are the same problem wearing a different hat. A <code>BEGIN</code> without a matching <code>COMMIT</code> or <code>ROLLBACK</code> keeps consuming RPUs until the session ends. Session timeouts exist precisely because this happens.</p>



<p class="wp-block-paragraph">What I would check, in this order:</p>



<ol class="wp-block-list"><li>Disable the pool&#8217;s validation or heartbeat query entirely if the driver allows it. If it does not, stretch the interval as far as your failure tolerance permits.</li><li>Drop idle pool size to something honest. A pool sized for peak that stays warm overnight is pure waste on this pricing model.</li><li>Fix any code path that opens a transaction and returns early on error without ending it.</li><li>Set a session timeout per application role so a leaked connection cannot bill indefinitely.</li></ol>



<pre class="wp-block-code"><code>-- Cap idle sessions for the application role.
-- Value is in seconds; the documented range is 60 to 1,728,000.
ALTER USER analytics_app SESSION TIMEOUT 1800;

-- Cap how many connections a single role can hold open at once.
ALTER USER analytics_app CONNECTION LIMIT 40;

-- What is connected right now, and with what timeout.
SELECT * FROM stv_sessions;</code></pre>



<p class="wp-block-paragraph">Session timeout changes apply to new sessions only, so recycle the pool afterwards or you will conclude the setting does nothing.</p>



<h2 class="wp-block-heading">Lever two: base capacity, max capacity and usage limits are three different things</h2>



<p class="wp-block-paragraph">These get conflated constantly, and two of them will not save you a cent on their own.</p>



<ul class="wp-block-list"><li><strong>Base capacity (base RPU).</strong> The floor. It multiplies every billed second, including that 60-second minimum. Halving base capacity roughly halves the cost of a warehouse dominated by short queries. It also halves the compute those queries get, so watch p95 latency alongside the bill.</li><li><strong>Max capacity (MaxRPU).</strong> A ceiling on how far automatic scaling can go. It caps compute available to the workgroup, it does not stop queries and it does not interrupt anything running. Useful as a guard rail against a runaway scan, useless as a budget.</li><li><strong>Usage limits.</strong> An actual budget, expressed in RPU-hours over a daily, weekly or monthly period. The breach actions are: log to a system table, raise an SNS alert, or turn off user queries.</li></ul>



<p class="wp-block-paragraph">Only the third one can stop you spending money, and only the third one can take your product down at 2pm on a Tuesday. Set it to alert first, live with it for a full billing cycle so you learn the shape of a normal week, then decide whether you are genuinely willing to have queries turned off. In a customer-facing SaaS product the answer is usually no, and the limit stays as an alarm feeding PagerDuty or whatever you already page from.</p>



<p class="wp-block-paragraph">There is also the price-performance target, the slider that hands scaling decisions to AWS in exchange for a stated cost or speed preference. AWS recommends it for mid-range base capacities and advises against it at the very bottom and very top of the RPU scale, so check the current guidance against your base setting before enabling it. It is worth trying on a staging workgroup with a replayed query mix; it is not worth switching on blind in production.</p>



<p class="wp-block-paragraph">On provisioned clusters the equivalent controls are per-feature usage limits: concurrency scaling measured in time, Spectrum measured in data scanned, cross-region data sharing, and extra compute for automatic optimization. Each takes a breach action of log, emit a metric, or disable the feature. Concurrency scaling also earns free credits as the main cluster runs, which is why a moderately bursty provisioned cluster often shows no concurrency scaling charge at all until it suddenly does.</p>



<h2 class="wp-block-heading">Lever three: Redshift cost optimization starts with knowing which tenant is expensive</h2>



<p class="wp-block-paragraph">Be clear-eyed about what is possible here. On serverless you cannot get an exact dollar figure per query, because billing happens at the warehouse level and the minimum charge is shared across whatever else was running in that window. What you can build is a defensible apportionment, and that is enough to find the customer whose scheduled export is quietly eating your margin.</p>



<p class="wp-block-paragraph">Start by labelling every statement your API issues on a tenant&#8217;s behalf.</p>



<pre class="wp-block-code"><code>-- Set in the pool's per-checkout init SQL, or per request.
SET query_group TO 'tenant_4417';

SELECT metric_date, sum(events)
FROM   fact_events
WHERE  tenant_id = 4417
  AND  metric_date &gt;= dateadd(day, -30, current_date)
GROUP  BY 1;

RESET query_group;</code></pre>



<p class="wp-block-paragraph">The label lands in the query log and surfaces as <code>query_label</code> in the SYS monitoring views. Keep it short: the older query log views truncate the label to 30 characters, so a tenant slug beats a UUID with prefixes bolted on.</p>



<pre class="wp-block-code"><code>-- Seven days of activity grouped by tenant label.
-- Note: time columns in the SYS views are microseconds;
-- confirm units before converting anything to money.
SELECT trim(query_label)   AS tenant,
       count(*)            AS queries,
       sum(execution_time) AS exec_time,
       sum(queue_time)     AS queue_time
FROM   sys_query_history
WHERE  start_time &gt; dateadd(day, -7, sysdate)
  AND  query_label LIKE 'tenant_%'
GROUP  BY 1
ORDER  BY exec_time DESC;</code></pre>



<p class="wp-block-paragraph">Three columns in that view earn their keep beyond the obvious ones. <code>result_cache_hit</code> tells you which dashboard queries are already free, which is often a bigger share than people expect. The split between <code>queue_time</code> and <code>execution_time</code> tells you whether you have a tuning problem or a capacity problem, and those have opposite fixes. And <code>user_query_hash</code> groups repeated queries with different literals, which is exactly what an embedded dashboard produces, so it is the fastest way to find the one panel that fifty tenants are running badly.</p>



<p class="wp-block-paragraph">From there, apportion the day&#8217;s <code>charged_seconds</code> by each tenant&#8217;s share of execution time. It is an approximation and you should label it as one when you show it to finance. It is still the difference between &#8220;Redshift costs us a lot&#8221; and &#8220;eleven percent of our warehouse spend is one customer pulling an unbounded date range every fifteen minutes.&#8221;</p>



<h2 class="wp-block-heading">Lever four: the isolation model you picked is a cost decision</h2>



<p class="wp-block-paragraph">AWS&#8217;s SaaS guidance describes three partitioning models, and each one has a distinct cost signature on Redshift.</p>



<ul class="wp-block-list"><li><strong>Pool.</strong> All tenants share tables with a tenant identifier column. Cheapest by a wide margin, one warehouse to keep warm, one set of statistics. You pay for it in noisy-neighbour risk and in the access-control work you now have to do yourself.</li><li><strong>Bridge.</strong> Separate schemas or databases inside one cluster. Sounds like a compromise, behaves like neither. AWS&#8217;s own whitepaper is fairly blunt that the isolation profile does not usually justify it, since cluster-level access grants reach across the databases anyway.</li><li><strong>Silo.</strong> A warehouse per tenant. Clean boundaries and per-tenant cost visibility for free. On serverless it is also the most expensive thing you can do, because every workgroup carries its own base capacity floor and its own 60-second minimums. Twenty small tenants means twenty warehouses waking up independently.</li></ul>



<p class="wp-block-paragraph">Data sharing sits between these and is the pattern I reach for when workload interference is the real problem. One producer handles ingestion and transformation; consumers read the shared data without copying it, and a consumer&#8217;s load does not touch the producer. Genuinely useful for separating a heavy ETL window from customer-facing reads. But be honest about the arithmetic: every consumer is its own billable warehouse. Data sharing buys you performance isolation, not cheaper compute.</p>



<p class="wp-block-paragraph">In a pooled model, the thing I set up first is a sort key that leads with the tenant identifier followed by the time column everyone filters on. That lets Redshift prune blocks before it reads them instead of scanning broadly and filtering afterwards. Combine it with row-level security so the tenant predicate cannot be forgotten by an application bug, and you have removed both the largest cost driver and the scariest failure mode in one change.</p>



<h2 class="wp-block-heading">Lever five: scan less, refresh less</h2>



<p class="wp-block-paragraph">Classic warehouse hygiene still applies, it just pays differently here. Shorter queries mean fewer billed seconds at your base capacity.</p>



<ul class="wp-block-list"><li><strong>Sort keys that match your real predicates.</strong> Not the ones from the design doc. Pull the top twenty query hashes and read their WHERE clauses.</li><li><strong>Materialized views for the panels every tenant loads.</strong> Real savings on the read path, but refresh is compute you pay for. A view refreshed every five minutes and read twice an hour is a net loss.</li><li><strong>Let the result cache work.</strong> Identical query text against unchanged data is free. Anything your BI layer does that injects a timestamp or a random parameter into otherwise identical SQL is throwing that away. Worth checking in Amazon QuickSight, Metabase or whatever sits in front.</li><li><strong>Tune zero-ETL refresh intervals.</strong> The refresh interval on the target database is adjustable via <code>ALTER DATABASE</code>. Shorter is fresher and more expensive. For reporting and historical analysis, a longer interval is usually the right call and nobody notices.</li><li><strong>Keep cold history out of managed storage.</strong> Partitioned Parquet or Apache Iceberg tables in S3, catalogued in AWS Glue, queried through the lake. On serverless those queries bill at the same RPU rate rather than as a separate Spectrum line, so the win is in scan efficiency and storage cost, not in dodging a charge.</li></ul>



<p class="wp-block-paragraph">One reassuring detail: the automatic optimization work Redshift does in the background is not billed by default. It becomes billable only if you explicitly enable extra compute resources so those operations can run during busy periods. That is a deliberate trade, not an accident, and it is worth knowing before you turn it on.</p>



<h2 class="wp-block-heading">Provisioned or serverless: how I would decide</h2>



<p class="wp-block-paragraph">Both have a genuine case and the honest answer depends on the shape of your load, not on which is newer.</p>



<p class="wp-block-paragraph">Serverless wins when demand is spiky or concentrated in business hours, when you cannot forecast capacity, and for dev and test environments that sit idle most of the week. It also folds concurrency scaling and data-lake queries into a single rate, which removes two line items people routinely forget to model.</p>



<p class="wp-block-paragraph">Provisioned RA3 wins when load is steady around the clock, because a reserved commitment on nodes can beat accumulated on-demand RPU-hours, and because you get the full workload management surface: queues, query priority, query monitoring rules with the complete set of controls. If you need to guarantee that a tenant&#8217;s export can never starve the interactive path, that machinery is more expressive than a price-performance slider.</p>



<p class="wp-block-paragraph">Commitment discounts now exist on both sides, including reservations for serverless managed at the payer account level. Rates and terms change, so price it against your own numbers rather than a blog post.</p>



<p class="wp-block-paragraph">The tell is simple. Pull a week of <code>charged_seconds</code> bucketed by hour and plot it. A flat line means you are paying serverless rates for provisioned behaviour. A sawtooth with long dead zones means the opposite.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: the bill moved and nothing shipped</h2>



<ol class="wp-block-list"><li><strong>Get hourly billed seconds first.</strong> Aggregate <code>charged_seconds</code> by hour from the usage view. If the increase is spread evenly across all 24 hours, it is background activity: a pool, a monitor, a health check. If it is concentrated, it is a workload.</li><li><strong>Check for anything running or queued right now.</strong> A single stuck statement explains a lot of otherwise inexplicable spend.</li><li><strong>Look for transactions that never ended.</strong> A deploy that changed error handling can leave transactions open without a single failed request in your logs.</li><li><strong>Compare query counts against query cost.</strong> Flat count with rising cost points at base capacity changes, scale-down lag, or data growth making the same queries slower.</li><li><strong>Group by <code>user_query_hash</code> and diff against last week.</strong> New shapes appearing means a shipped change. Old shapes getting slower means data or statistics.</li><li><strong>Only then look at storage.</strong> Managed storage grows quietly and it is rarely the cause of a sudden jump, but it is often the cause of a slow one.</li></ol>



<pre class="wp-block-code"><code>-- Anything currently running or waiting.
SELECT user_id, query_id, transaction_id, session_id, status,
       trim(database_name) AS database_name,
       start_time, queue_time, execution_time
FROM   sys_query_history
WHERE  status IN ('running','queued')
ORDER  BY start_time;</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list"><li>Treating max capacity as a spending cap. It caps compute, not cost, and it will not stop a workload that simply runs for a long time.</li><li>Optimising individual slow queries while ignoring a connection pool that wakes the warehouse every thirty seconds all night.</li><li>Smoothing scheduled jobs out across the hour to be gentle on the warehouse. On serverless this is backwards; batching into fewer windows costs less.</li><li>Building cost dashboards on <code>compute_seconds</code> instead of <code>charged_seconds</code>, then wondering why the totals never reconcile with the invoice.</li><li>Giving every tenant their own workgroup for isolation, then discovering that base capacity floors and minimum charges multiply by tenant count.</li><li>Setting a usage limit to &#8220;turn off user queries&#8221; on the first day, before anyone knows what a normal week looks like.</li><li>Leaving the monitoring loop itself unbounded. Polling system views every few seconds is a workload that bills like any other.</li></ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list"><li>Label every tenant-originated query with <code>query_group</code> from day one. Retrofitting attribution is far more painful than adding a SET statement to your pool&#8217;s init SQL.</li><li>UNLOAD the serverless usage view to S3 on a schedule. Seven days of retention is not enough to argue about a monthly invoice.</li><li>Keep at least one usage limit configured as an alert, permanently, even if you never set a hard cap.</li><li>Review base capacity quarterly against p95 latency, not just against cost. The right number moves as your workload changes.</li><li>Put a hard date bound on every customer-facing query in the application layer. Unbounded ranges are the single most common source of surprise spend in embedded analytics.</li><li>Model concurrency scaling and data-lake charges explicitly if you are on provisioned. They are the line items people forget until they appear.</li><li>Tag workgroups and clusters consistently so cost tooling, whether that is AWS Cost Explorer or something like CloudZero or Vantage, can split spend by environment without guesswork.</li></ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Does Redshift Serverless really charge me when nobody is using the product?</h3>



<p class="wp-block-paragraph">Idle time itself is not billed, but anything that sends a query is. AWS states explicitly that health-check queries from connection pools count as billable user activity. If your pool validates connections on a timer overnight, you are paying minimum billing windows all night. Check the pool before you conclude the pricing model is broken.</p>



<h3 class="wp-block-heading">How do I calculate the cost of a single query?</h3>



<p class="wp-block-paragraph">You cannot, exactly. Serverless bills the warehouse, and the 60-second minimum is shared with whatever else ran in that window. The workable approach is apportionment: take <code>charged_seconds</code> for a period and divide it by each labelled tenant&#8217;s share of execution time from the query history view. Useful for finding outliers, not precise enough for per-customer invoicing.</p>



<h3 class="wp-block-heading">Should I lower base capacity to save money?</h3>



<p class="wp-block-paragraph">Often yes, and it is the single highest-leverage change for a workload made of many short queries, because base capacity multiplies every billed second including the minimum. The catch is that it also reduces the compute each query gets. Change it in one step, watch p95 latency and queue time together for a full week, then decide whether to go further.</p>



<h3 class="wp-block-heading">Is a warehouse per tenant a good idea?</h3>



<p class="wp-block-paragraph">Only when tenants are large enough to keep a warehouse genuinely busy, or when a contract requires that level of separation. For a long tail of small tenants it is the most expensive option available, since each warehouse carries its own capacity floor and its own minimum charges. Pooled tables with row-level security and a tenant-leading sort key gets you most of the isolation for a fraction of the compute.</p>



<h3 class="wp-block-heading">Does concurrency scaling cost extra?</h3>



<p class="wp-block-paragraph">On Redshift Serverless, no, scaling is included in the RPU rate. On provisioned clusters it is a separate charge, offset by credits that accrue while the main cluster runs. That difference catches out teams migrating between the two, in both directions.</p>



<h3 class="wp-block-heading">Will a usage limit take my product down?</h3>



<p class="wp-block-paragraph">It will if you configure the breach action to turn off user queries. The logging and alerting actions are safe and are what you want in a customer-facing system. Treat the hard stop as a deliberate business decision about which is worse, an unexpected invoice or an outage, rather than as a default setting.</p>



<h3 class="wp-block-heading">Why does my cost report never match the AWS invoice?</h3>



<p class="wp-block-paragraph">Usually one of three things: using <code>compute_seconds</code> rather than <code>charged_seconds</code>, forgetting that usage is recorded only when a transaction completes so long transactions land in a later interval, or leaving storage and cross-region transfer out of the model entirely.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Redshift cost optimization for a SaaS analytics product is mostly not a query tuning exercise. It is a question of how often something wakes the warehouse up and at what capacity. Query tuning matters, sort keys matter, materialized views matter, but a connection pool with default settings will quietly outspend all of them combined.</p>



<p class="wp-block-paragraph">So start at the meter. Pull hourly billed seconds, look at the overnight hours when your product has no users, and see whether the line goes to zero. If it does not, you have found your first and cheapest win before touching a single line of SQL.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help getting your Redshift bill under control?</h2>



<p class="wp-block-paragraph">I work with SaaS and data teams on exactly this problem, usually somewhere between the warehouse and the application that is hammering it. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing an existing Redshift Serverless or RA3 workload and producing a ranked list of what is actually driving spend, with the numbers pulled from your own system views.</li><li>Building per-tenant cost attribution: query labelling, a usage archive in S3, and a dashboard your product and finance teams can both read.</li><li>Fixing the connection and session layer, including pool configuration, validation queries, session timeouts and transaction hygiene.</li><li>Right-sizing base and max capacity against measured latency, and setting usage limits and alerts that warn without risking an outage.</li><li>Reviewing multi-tenant data models: sort and distribution keys, row-level security, and whether data sharing or a pooled model fits your tenant mix.</li><li>Deciding between provisioned and serverless with a workload profile behind the recommendation rather than a rule of thumb.</li></ul>



<p class="wp-block-paragraph">If you have a week of usage data, a suspicious hourly cost chart, or a pool configuration you are not sure about, send it over and I will tell you what I see in it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/redshift-cost-optimization-saas-analytics/">Redshift Cost Optimization for SaaS Analytics: The Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/redshift-cost-optimization-saas-analytics/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Embedded Analytics on AWS: The Four Decisions That Bite Later</title>
		<link>https://john-nessime.com/blog/devops/embedded-analytics-on-aws/</link>
					<comments>https://john-nessime.com/blog/devops/embedded-analytics-on-aws/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 06:07:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon Redshift]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[Embedded Analytics]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Multi-Tenant]]></category>
		<category><![CDATA[QuickSight]]></category>
		<category><![CDATA[Row-Level Security]]></category>
		<category><![CDATA[SPICE]]></category>
		<category><![CDATA[SQL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=120</guid>

					<description><![CDATA[<p>Rendering a dashboard inside your app is the easy part. Tenant isolation, session cost and query mode are what break. A practical walkthrough of the four decisions behind embedded analytics on AWS, the API constraints that lock you in, and the errors you will actually see.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/embedded-analytics-on-aws/">Embedded Analytics on AWS: The Four Decisions That Bite Later</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Getting a dashboard to render inside your own application is the easy part. You publish it, call the embed API, drop the iframe in, and it shows up. The hard question arrives about a day later, usually from someone in security or from the first customer who logs in: how exactly does tenant B not see tenant A&#8217;s rows?</p>



<p class="wp-block-paragraph">That is where embedded analytics on AWS stops being a front-end task and becomes an architecture decision. The awkward part is that the choice you make first, how the viewer is identified, quietly decides which isolation mechanisms remain available to you afterwards. Get that order backwards and you rebuild the data layer, not the iframe.</p>



<p class="wp-block-paragraph">This post covers the four decisions that determine whether the build holds: identity model, tenant isolation, query mode, and session economics. Then the embed handshake itself, the errors you will actually see in the browser console, and what to check first when it fails.</p>



<h2 class="wp-block-heading">Before anything else: the product got renamed</h2>



<p class="wp-block-paragraph">Amazon QuickSight was folded into a broader platform called Amazon Quick Suite, and the BI product inside it is now called Amazon Quick Sight. AWS documentation has since moved again under an &#8220;Amazon Quick&#8221; umbrella. You will land on all three naming conventions depending on which search result you click, which makes finding the right doc page genuinely annoying.</p>



<p class="wp-block-paragraph">The practical upshot: the APIs, SDKs and IAM action names did not change. You are still calling <code>quicksight:GenerateEmbedUrlForRegisteredUser</code> against ARNs in the <code>quicksight</code> namespace, and the JavaScript SDK is still published as <code>amazon-quicksight-embedding-sdk</code>. Nothing in your code breaks. Only your bookmarks do. I mention it because half the confusion in a first embedded build comes from following a doc page that describes a UI menu that has since been reorganised.</p>



<h2 class="wp-block-heading">Decision one: registered users or anonymous sessions</h2>



<p class="wp-block-paragraph">Two API operations generate embed URLs. <code>GenerateEmbedUrlForRegisteredUser</code> issues a session for a user who exists inside the BI account. <code>GenerateEmbedUrlForAnonymousUser</code> issues a session for someone who does not, and never will.</p>



<p class="wp-block-paragraph">This reads like a convenience choice. It is not. Row-level security using session tags, the mechanism most SaaS products want, is supported <em>only</em> for anonymous embedding. It does not work with <code>GenerateEmbedUrlForRegisteredUser</code>, it does not work with the older <code>GetDashboardEmbedUrl</code> operation, and it is not supported with the IAM identity type. That constraint is documented, easy to miss, and it is the single most expensive thing to discover late.</p>



<p class="wp-block-paragraph">So the fork is really this. If you register every viewer, you get per-user features (bookmarks, threshold alerts, scheduled snapshots) and you enforce isolation with username or group rules on the dataset. You also inherit the job of provisioning, deprovisioning and reconciling a user directory that mirrors your own. If you go anonymous, you skip all of that and filter with session tags at embed time, but per-user features are off the table because there is no persistent user to hang them on.</p>



<p class="wp-block-paragraph">The registered-user request body is small. Everything interesting is in <code>ExperienceConfiguration</code>:</p>



<pre class="wp-block-code"><code>POST /accounts/&lt;aws-account-id&gt;/embed-url/registered-user

{
  "UserArn": "arn:aws:quicksight:&lt;region&gt;:&lt;account&gt;:user/default/&lt;user&gt;",
  "SessionLifetimeInMinutes": 60,
  "AllowedDomains": ["https://app.example.com"],
  "ExperienceConfiguration": {
    "Dashboard": {
      "InitialDashboardId": "&lt;dashboard-id&gt;",
      "FeatureConfigurations": {
        "Bookmarks": { "Enabled": true }
      }
    }
  }
}</code></pre>



<p class="wp-block-paragraph">One trap on the anonymous path that deserves its own sentence. Anonymous sessions belong to a namespace, and any dashboard shared with that namespace is reachable by a session in it, whether or not you listed the dashboard in <code>AuthorizedResourceArns</code>. If you were treating that parameter as your allowlist, it is not. Namespace membership is the real boundary.</p>



<h2 class="wp-block-heading">Decision two: where tenant isolation actually lives</h2>



<p class="wp-block-paragraph">There are three places you can put the filter, and only one of them scales.</p>



<ul class="wp-block-list">
<li><strong>A dashboard per tenant.</strong> Works for five customers. Becomes a deployment problem at fifty and a change-management disaster at five hundred, because every visual fix is now a fan-out.</li>



<li><strong>A dataset per tenant, filtered in SQL.</strong> Better isolation guarantees, genuinely defensible in a compliance review, but you multiply refresh jobs and in-memory footprint by tenant count.</li>



<li><strong>One dashboard, one dataset, row-level security.</strong> The standard answer. One artifact to maintain, filtering applied per session.</li>
</ul>



<p class="wp-block-paragraph">With anonymous embedding, RLS is driven by tags. You declare tag keys against columns on the dataset, then supply values at embed time. The filter is evaluated server-side against the session, so a viewer poking at the iframe cannot lift it.</p>



<pre class="wp-block-code"><code>POST /accounts/&lt;aws-account-id&gt;/embed-url/anonymous-user

{
  "Namespace": "default",
  "SessionLifetimeInMinutes": 60,
  "AuthorizedResourceArns": [
    "arn:aws:quicksight:&lt;region&gt;:&lt;account&gt;:dashboard/&lt;dashboard-id&gt;"
  ],
  "SessionTags": [
    { "Key": "tenant_id", "Value": "acme-corp" },
    { "Key": "region",    "Value": "emea" }
  ],
  "AllowedDomains": ["https://app.example.com"],
  "ExperienceConfiguration": {
    "Dashboard": { "InitialDashboardId": "&lt;dashboard-id&gt;" }
  }
}</code></pre>



<p class="wp-block-paragraph">The value in <code>SessionTags</code> must come from your server-side session, never from a request parameter, a cookie your client can write, or a JWT claim you have not verified. This is the whole security boundary. Tag rules support combining conditions, so a manager who should see several sites is expressible without a second dashboard.</p>



<p class="wp-block-paragraph">One quiet limit worth knowing before it bites: when RLS is applied to in-memory datasets, each field has a maximum length in Unicode characters, and fields exceeding it are truncated during ingestion rather than rejected. If your tenant identifiers are long opaque strings, test that a truncated value cannot collide with another tenant&#8217;s. Silent truncation plus a prefix collision is exactly the kind of bug that produces a cross-tenant data leak with no error anywhere in the logs.</p>



<h2 class="wp-block-heading">Decision three: SPICE or direct query against your AWS data</h2>



<p class="wp-block-paragraph">Every dataset runs in one of two modes, and the difference shows up on a bill somewhere else in your account.</p>



<p class="wp-block-paragraph"><strong>Direct query</strong> sends a live query to the source each time a visual renders. Against Amazon Athena that means an S3 scan per dashboard open, billed by bytes scanned. Against Amazon Redshift it means a concurrent query slot per viewer. Freshness is perfect. The failure mode is that dashboard load is now coupled to warehouse load, and your analytics traffic competes with everything else running there. Two hundred people opening a dashboard at 9am is two hundred queries, and Redshift concurrency is finite.</p>



<p class="wp-block-paragraph"><strong>SPICE</strong> imports a snapshot into an in-memory engine and serves every viewer from it. One scan on refresh, then arbitrarily many reads. For an embedded product where the same aggregate is served to thousands of sessions, this is usually the right call, and the Athena cost difference between &#8220;scan once per refresh&#8221; and &#8220;scan once per pageview&#8221; is not subtle. What you give up is freshness, bounded by your refresh schedule, plus a capacity dimension to manage and incremental refresh to configure if the dataset is large.</p>



<p class="wp-block-paragraph">The pattern I reach for first on a data-lake backend is a hybrid: recent partitions in SPICE with an incremental refresh on a look-back window, historical data left on direct query for the rare deep query. It costs more design effort up front and it is the thing most teams skip, but it is the only shape that keeps both the bill and the load time flat as history grows.</p>



<p class="wp-block-paragraph">Whichever you pick, note that visual generation has a timeout, and data-source-specific timeouts apply on top of it. A query that is merely slow in a console tab renders as a broken visual in a customer&#8217;s browser. Model your worst partition, not your average one.</p>



<h2 class="wp-block-heading">Decision four: what a session actually costs</h2>



<p class="wp-block-paragraph">I am not going to quote figures, because AWS changes them and you should read the current pricing page. The mechanism is what matters, and it is genuinely different from seat-based BI licensing.</p>



<ul class="wp-block-list">
<li>A reader session is a fixed 30-minute window. Not a pageview, not a query. Reopening the dashboard twenty minutes later is still the same session.</li>



<li><strong>Per-user pricing</strong> charges per session with a monthly cap per reader. Predictable when the same people return daily.</li>



<li><strong>Capacity pricing</strong> buys sessions in bulk with no user provisioning at all. This is the model built for embedding, and it is the one that pairs with anonymous sessions.</li>



<li>Capacity pricing is also the prerequisite for programmatic dashboard refresh, so if near-real-time rendering is a product requirement, that decision is already made for you.</li>



<li>Annual commitments to capacity unlock removing the &#8220;Powered by&#8221; attribution footer. If white-labelling is a contractual requirement, factor that in early rather than discovering it during a customer demo.</li>



<li>Enabling certain Pro-tier and generative Q&amp;A capabilities triggers an account-level monthly infrastructure fee that exists whether or not anyone uses the feature.</li>
</ul>



<p class="wp-block-paragraph">The cost failure mode nobody plans for is architectural rather than commercial. If you embed the dashboard on a tab that loads by default, you bill a session for every user who lands on that page and looks at something else. Lazy-load the iframe on explicit interaction. That one change is often the largest single lever on the bill, and it costs an afternoon.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The embed handshake, and the three things that break it</h2>



<p class="wp-block-paragraph">The flow is short. Your backend authenticates the user with your own identity system, calls the embed URL API with the right tags or user ARN, returns the URL to the browser, and the SDK mounts an iframe against it.</p>



<ol class="wp-block-list">
<li>The generated URL carries a temporary bearer token valid for five minutes, and it is single use once redeemed. Generate it per page load from your backend. Never cache it, never put it in a build artifact, never log it.</li>



<li>Session lifetime is separate from URL validity, set with <code>SessionLifetimeInMinutes</code>, and ranges from fifteen minutes to ten hours with ten hours as the default. Ten hours is almost never what you want for a customer-facing product. Match it to your own session, or shorter.</li>



<li>Domains must be allowed explicitly. An administrator configures static domains in the admin menu, and <code>AllowedDomains</code> on the API call can override that with up to three domains or subdomains per request. Add an <code>AllowedEmbeddingDomains</code> condition to the IAM policy of the calling role, or any developer with that permission can list any domain on the internet.</li>
</ol>



<p class="wp-block-paragraph">On the browser side, the v2 SDK creates an embedding context (which appends its own zero-pixel iframe to <code>body</code> for message passing) and then mounts the experience:</p>



<pre class="wp-block-code"><code>import { createEmbeddingContext } from 'amazon-quicksight-embedding-sdk';

const context = await createEmbeddingContext();

await context.embedDashboard(
  {
    url: embedUrl,                        // fetched from your backend, just now
    container: '#analytics',
    height: '600px',                      // acts as loading height below
    resizeHeightOnSizeChangedEvent: true,
  },
  {
    toolbarOptions: { export: false, undoRedo: false, reset: false },
    attributionOptions: { overlayContent: true },
    onMessage: async (event) =&gt; {
      if (event.eventName === 'ERROR_OCCURRED') {
        console.error(event.message.errorCode);
      }
    },
  }
);</code></pre>



<p class="wp-block-paragraph">Two details in there earn their place. <code>resizeHeightOnSizeChangedEvent</code> turns the <code>height</code> value into a loading placeholder and lets the frame grow to fit content, which is what stops the dashboard rendering into a 600px letterbox with an inner scrollbar. And <code>overlayContent</code> tells the layout to overlay the attribution footer rather than reserve extra height at the bottom for it.</p>



<h2 class="wp-block-heading">Troubleshooting embedded analytics on AWS</h2>



<p class="wp-block-paragraph">Almost every failure lands in one of these. Read the error code out of the <code>ERROR_OCCURRED</code> message before doing anything else.</p>



<ul class="wp-block-list">
<li><strong><code>Forbidden</code></strong> means the URL&#8217;s authentication code expired. You held the URL longer than five minutes, or you served it from a cache, or a retry redeemed it twice. Fix the generation path, not the permissions.</li>



<li><strong><code>Unauthorized</code></strong> means the session obtained from that code expired. Different problem, different fix: your <code>SessionLifetimeInMinutes</code> is shorter than how long people keep the tab open. Handle it by re-fetching a fresh URL and re-mounting rather than letting the frame sit there dead.</li>



<li><strong>Frame never appears at all.</strong> Check the <code>onChange</code> handler for <code>NO_CONTAINER</code> or <code>INVALID_CONTAINER</code>, which usually means you mounted before your target element existed, and for <code>INVALID_URL</code>, which means the URL shape does not match the experience method you called.</li>



<li><strong>Frame appears, dashboard does not.</strong> Nine times out of ten this is the domain allowlist. The request domain has to match what was allowed, including scheme and any subdomain, and a staging hostname that nobody added is the usual culprit.</li>



<li><strong>Modals render off-screen.</strong> A known consequence of auto-resizing height: an export dialog can open above the visible viewport. Listen for <code>MODAL_OPENED</code> and scroll the parent page to the frame position.</li>



<li><strong>Toolbar features silently missing.</strong> Bookmarks, threshold alerts and scheduling require both the SDK toolbar flag and the matching entry under <code>FeatureConfigurations</code> in the embed URL request, and they only exist on the registered-user path. Setting the client flag alone does nothing.</li>



<li><strong>First render is slow, later ones are fine.</strong> Direct query against a cold warehouse. Compare the same query in Athena or Redshift directly to confirm before blaming the BI layer.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Choosing registered-user embedding for the identity story, then discovering session-tag RLS is unavailable on that path.</li>



<li>Treating <code>AuthorizedResourceArns</code> as the security boundary instead of namespace membership.</li>



<li>Deriving a session tag value from anything the client can influence.</li>



<li>Generating the embed URL at build time, or caching it in a CDN, and then not understanding the <code>Forbidden</code> errors.</li>



<li>Leaving session lifetime at the ten-hour default in a customer-facing app.</li>



<li>Putting the dashboard on a default-loaded tab and paying for sessions nobody asked for.</li>



<li>Building the first version on direct query against Athena because it is quicker to wire up, then meeting the scan bill.</li>



<li>Forgetting that embedding and row-level security sit in the Enterprise tier, so a Standard-tier proof of concept proves nothing.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Decide the identity model before you build a single dataset. Everything downstream inherits it.</li>



<li>Put the embed URL call behind one server-side endpoint that reads tenant scope from your own session and nowhere else. One function, one place to audit.</li>



<li>Constrain the calling IAM role with an <code>AllowedEmbeddingDomains</code> condition and scope resources to specific namespaces rather than a wildcard.</li>



<li>Write an automated test that requests tenant A&#8217;s embed URL and asserts tenant B&#8217;s rows are absent. Run it on every dataset change, because RLS breaks silently.</li>



<li>Default to SPICE with a refresh schedule matched to a stated freshness SLA, and only reach for direct query where the SLA genuinely demands it.</li>



<li>Track refresh failures as a first-class alert in CloudWatch or whatever you already run, whether that is Grafana, Datadog or something in-house. A stale dashboard that still renders is worse than one that errors, because nobody notices.</li>



<li>Lazy-load the iframe on user intent, not on page mount.</li>



<li>Keep the embedded surface read-only unless authoring is a real product requirement. Console embedding is a much larger permissions surface than dashboard embedding.</li>
</ul>



<h2 class="wp-block-heading">Is managed BI even the right call?</h2>



<p class="wp-block-paragraph">Worth asking honestly, because the answer is not always yes. The case for the AWS-native route is real: no connector layer to maintain against Athena, Redshift, S3 and Aurora, IAM you already understand, and a usage-based cost model that beats per-seat licensing when your viewers are bursty. If most of your data already sits in AWS, that adds up.</p>



<p class="wp-block-paragraph">The case against is equally real. Visual customisation is limited compared to charting directly against your own API, the attribution footer needs a commitment to remove, and if you want full control of the front end you may be better served by Apache Superset or Metabase self-hosted, or by Grafana where the workload is closer to operational metrics than customer-facing BI. Those come with an operational burden you now own. That is the trade: you either run the BI layer or you rent it, and renting it means living inside its constraints.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do my users need AWS accounts to view an embedded dashboard?</h3>



<p class="wp-block-paragraph">No. With anonymous embedding they need no AWS account and no BI user record at all. Your application authenticates them however you already do, and your backend maps that identity to session tags when it requests the embed URL.</p>



<h3 class="wp-block-heading">Can I use row-level security with registered-user embedding?</h3>



<p class="wp-block-paragraph">Yes, but only with username or group based rules, not with session tags. Tag-based RLS is restricted to the anonymous embedding path. If you need tags, you need anonymous sessions.</p>



<h3 class="wp-block-heading">How long does an embed URL stay valid?</h3>



<p class="wp-block-paragraph">The URL itself carries a bearer token valid for five minutes and usable once. The session it opens is separate and lasts between fifteen minutes and ten hours depending on <code>SessionLifetimeInMinutes</code>, defaulting to ten hours.</p>



<h3 class="wp-block-heading">Should I use SPICE or direct query for embedded analytics on AWS?</h3>



<p class="wp-block-paragraph">SPICE for anything with many viewers per refresh, which describes most embedded products. Direct query where the data must be current to the second, or where the dataset exceeds what you want to hold in memory. A hybrid split by data age is often the right answer and is under-used.</p>



<h3 class="wp-block-heading">Why do I get a Forbidden error when the dashboard worked yesterday?</h3>



<p class="wp-block-paragraph"><code>Forbidden</code> points at the URL, not at permissions. The most common causes are caching the URL, generating it more than five minutes before use, or a client retry redeeming the same single-use token twice. If it is <code>Unauthorized</code> instead, the session expired and you need a fresh URL.</p>



<h3 class="wp-block-heading">Can I white-label the embedded dashboard completely?</h3>



<p class="wp-block-paragraph">Largely. Themes control colours and typography, the SDK hides toolbar controls, and parameters let your own UI drive the dashboard. Removing the attribution footer entirely is tied to an annual capacity commitment, so confirm that against current terms before you promise it to a customer.</p>



<h3 class="wp-block-heading">Does natural-language querying work in an embedded context?</h3>



<p class="wp-block-paragraph">Yes. The SDK exposes a generative Q&amp;A experience alongside dashboards and visuals, driven by curated topics rather than raw tables. It is billed on its own capacity dimension and gates behind the Pro tiers, so treat it as a separate cost decision rather than a free addition.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Embedded analytics on AWS is not a rendering problem. The iframe is the last five percent. The part that decides whether the build survives contact with a second customer is the identity model, because it silently determines which isolation mechanism you are allowed to use, and that in turn shapes your dataset design, your refresh strategy and your bill.</p>



<p class="wp-block-paragraph">Pick that first. Write the cross-tenant test before you write the dashboard. Everything else is recoverable in an afternoon.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help with an embedded analytics build?</h2>



<p class="wp-block-paragraph">This is the kind of work I do. Things I can help with directly:</p>



<ul class="wp-block-list">
<li>Reviewing an existing embed integration for cross-tenant leakage, including the session-tag path and the namespace boundary.</li>



<li>Designing the identity and row-level security model before you commit to a dataset layout.</li>



<li>Cutting Athena scan and Redshift concurrency cost by moving the right datasets into SPICE with incremental refresh.</li>



<li>Building the backend embed-URL service with scoped IAM roles, domain conditions and sane session lifetimes.</li>



<li>Setting up refresh failure alerting so a stale dashboard does not quietly serve last week&#8217;s numbers.</li>



<li>Automated cross-tenant isolation tests wired into CI, so an RLS regression fails the build instead of the customer.</li>
</ul>



<p class="wp-block-paragraph">Send me the actual thing: your embed URL request payload with secrets stripped, the browser console error, or the dataset RLS rules. It is much faster to reason about a real payload than a description of one.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/embedded-analytics-on-aws/">Embedded Analytics on AWS: The Four Decisions That Bite Later</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/embedded-analytics-on-aws/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>The Column That Never Shows Up: Fixing Redshift and Zoho Analytics Schema Mismatches</title>
		<link>https://john-nessime.com/blog/technical-guides/zoho-analytics-redshift-schema-mismatch/</link>
					<comments>https://john-nessime.com/blog/technical-guides/zoho-analytics-redshift-schema-mismatch/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 02 Aug 2026 08:30:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Amazon Redshift]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[Database Optimization]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Schema Design]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[Zoho Analytics]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=76</guid>

					<description><![CDATA[<p>You added a column in Redshift, clicked Sync Design, and it reported success. The column still isn't in the dashboard. That's documented behaviour: one unresolved mismatch anywhere blocks new column information everywhere. Here's how to clear them and stop them coming back.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/zoho-analytics-redshift-schema-mismatch/">The Column That Never Shows Up: Fixing Redshift and Zoho Analytics Schema Mismatches</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone from finance asks why <code>discount_amount</code> isn&#8217;t showing up in the dashboard. You added it to the Redshift table last week. You went into the connection settings, clicked Sync Design, waited, and it reported success. The column is still not there.</p>



<p class="wp-block-paragraph">Nothing failed. Sync Design ran exactly as designed. What it did not do is fetch any new column information, because there is an unresolved mismatch sitting in the connection, on a different table, from a rename somebody did two months ago that nobody noticed. Until that is cleared, design sync will keep running and keep declining to pick up anything new.</p>



<p class="wp-block-paragraph">That behaviour is documented, not a bug, and it is the single most useful thing to know about a <strong>Zoho Analytics Redshift schema mismatch</strong>. Mismatches are not independent little problems you can leave lying around. One of them jams the mechanism for all of them.</p>



<p class="wp-block-paragraph">This walks through the three families these problems come in, names, types and time, the fix for each, and the structural change that stops them recurring: stop pointing the BI tool at your base tables.</p>



<h2 class="wp-block-heading">First: work out which mode you are actually in</h2>



<p class="wp-block-paragraph">Zoho Analytics connects to Redshift two completely different ways, and half the confusion in this area comes from people reading advice written for the other one. Whoever set the connection up may have left, so check rather than assume.</p>



<p class="wp-block-paragraph"><strong>Data Import</strong> copies the data into Zoho Analytics on a schedule. Reports are fast because they run against Zoho&#8217;s own storage. In this mode:</p>



<ul class="wp-block-list">
<li>Column additions and deletions are synchronised automatically.</li>
<li>You <em>can</em> change a column&#8217;s data type inside Zoho Analytics, but the type has to stay compatible with the Redshift column or subsequent syncs fail. Zoho&#8217;s own guidance is to change it in both places, which is worth taking literally.</li>
<li>You can create query tables, and import a filtered subset using a custom query.</li>
</ul>



<p class="wp-block-paragraph"><strong>Live Connect</strong> keeps nothing locally and queries Redshift when a report loads. It is available on the paid tiers only. In this mode:</p>



<ul class="wp-block-list">
<li>Column additions, deletions and renames are <em>not</em> synchronised automatically. You have to trigger Sync Design from the Edit Redshift Settings page.</li>
<li>You <em>cannot</em> change a column&#8217;s data type in Zoho Analytics at all. Whatever Redshift says, that is what you get.</li>
<li>No query tables, and you cannot pull other data sources into that workspace.</li>
<li>Foreign keys defined in Redshift become lookup relationships automatically, which is a genuine advantage over Data Import, where you build those by hand.</li>
<li>Report loading time is your Redshift cluster&#8217;s problem now.</li>
</ul>



<p class="wp-block-paragraph">The practical consequence: in Live Connect, every schema fix has to happen in Redshift. There is no BI-side escape hatch. That constraint sounds annoying and is actually the thing that pushes you toward the right architecture, which is the last section of this post.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Family 1: names</h2>



<p class="wp-block-paragraph">Zoho keeps its own copy of the table and column names it expects. When Redshift&#8217;s names drift away from that copy, the difference shows up in the Mismatch tab of the connection settings, and the two most common causes are both silent.</p>



<h3 class="wp-block-heading">Case folding</h3>



<p class="wp-block-paragraph">Redshift lowercases unquoted identifiers. Somebody writes what looks like a camel-case column name, Redshift stores something else, and the BI tool is now looking for a column that does not exist under that name.</p>



<pre class="wp-block-code"><code>-- These two do NOT create the same column.
ALTER TABLE analytics.orders ADD COLUMN DiscountAmount DECIMAL(12,2);
-- ...stored as: discountamount

ALTER TABLE analytics.orders ADD COLUMN "DiscountAmount" DECIMAL(12,2);
-- ...stored as: DiscountAmount</code></pre>



<p class="wp-block-paragraph">Pick one convention, lowercase with underscores, and enforce it. Mixed quoting across a schema means some columns are case-sensitive and some are not, and you will spend an afternoon working out which.</p>



<h3 class="wp-block-heading">See what Redshift actually has</h3>



<p class="wp-block-paragraph">Before touching anything in Zoho, get the ground truth. Use <code>SVV_COLUMNS</code> rather than <code>PG_TABLE_DEF</code>, because the latter only returns rows for schemas that happen to be in your <code>search_path</code> and silently returns nothing otherwise, which has wasted a lot of people&#8217;s time.</p>



<pre class="wp-block-code"><code>SELECT table_name,
       column_name,
       ordinal_position,
       data_type,
       character_maximum_length,
       numeric_precision,
       numeric_scale
FROM svv_columns
WHERE table_schema = 'analytics'
ORDER BY table_name, ordinal_position;</code></pre>



<p class="wp-block-paragraph">Export that, put it next to the Mismatch tab, and work down the list. Guessing from memory is how you resolve four mismatches and leave the fifth.</p>



<h3 class="wp-block-heading">Renames and drops</h3>



<p class="wp-block-paragraph">A rename upstream reads to Zoho as one column disappearing and an unrelated one appearing. If a report or formula referenced the old name, you will also see the alert about a view that cannot be accessed because of changes made to the table. The fix there is to re-synchronise the table from the connection settings, but re-syncing will not help while the Mismatch tab still has entries.</p>



<p class="wp-block-paragraph">So the order is fixed: <strong>clear every mismatch first, then Sync Design, then fix reports.</strong> Doing it in any other order produces the &#8220;I clicked sync and nothing happened&#8221; experience.</p>



<h2 class="wp-block-heading">Family 2: types</h2>



<p class="wp-block-paragraph">Redshift has a rich type system. A BI tool has maybe a dozen column types. The mapping is lossy in places, and the losses are quiet.</p>



<h3 class="wp-block-heading">TEXT and BPCHAR are not what they look like</h3>



<p class="wp-block-paragraph">This one catches people migrating from PostgreSQL, where <code>TEXT</code> is unbounded. In Redshift it is an alias that becomes <code>VARCHAR(256)</code>, and <code>BPCHAR</code> becomes <code>CHAR(256)</code>. Longer values get rejected or truncated depending on how they arrive, and the column reaching Zoho is a 256-character string rather than the free text you thought you had.</p>



<pre class="wp-block-code"><code>-- Looks unbounded. Is not.
CREATE TABLE staging.notes (body TEXT);        -- VARCHAR(256)

-- Say what you mean. 65535 bytes is the VARCHAR maximum.
CREATE TABLE staging.notes (body VARCHAR(65535));</code></pre>



<h3 class="wp-block-heading">VARCHAR length is measured in bytes</h3>



<p class="wp-block-paragraph">Not characters. An accented Latin character costs two bytes, most CJK characters three, an emoji four. A <code>VARCHAR(50)</code> holds fifty English letters or twelve emoji. Names, addresses and free-text fields with international data hit this constantly, and the symptom in the dashboard is a truncated string rather than an error.</p>



<pre class="wp-block-code"><code>-- LENGTH counts characters, OCTET_LENGTH counts bytes.
-- The second number is the one that has to fit.
SELECT MAX(LENGTH(customer_name))       AS max_chars,
       MAX(OCTET_LENGTH(customer_name)) AS max_bytes
FROM analytics.customers;</code></pre>



<p class="wp-block-paragraph">Widening a VARCHAR is one of the few in-place alterations Redshift allows. Narrowing one, or changing a column&#8217;s type outright, generally means rebuilding the table, so size these deliberately at creation rather than planning to fix them later.</p>



<h3 class="wp-block-heading">Numbers and precision</h3>



<p class="wp-block-paragraph">A <code>DECIMAL(38,10)</code> is a perfectly reasonable warehouse column and an awkward BI column. Currency stored as a float is worse, because you get rounding that appears only in the total row and only sometimes, which is a genuinely unpleasant thing to debug in front of a finance team.</p>



<p class="wp-block-paragraph">Cast money to a fixed scale before it leaves Redshift. Two decimal places, <code>DECIMAL</code> not <code>FLOAT</code>, decided once in the warehouse rather than per-report in the BI tool.</p>



<h3 class="wp-block-heading">SUPER, and anything else with no BI equivalent</h3>



<p class="wp-block-paragraph">Semi-structured <code>SUPER</code> columns, <code>VARBYTE</code>, <code>GEOMETRY</code>, <code>HLLSKETCH</code>: there is no sensible column type on the other side. Do not expose them. Flatten what you need into typed scalar columns in a view and leave the rest in the warehouse.</p>



<h3 class="wp-block-heading">If you are in Data Import mode</h3>



<p class="wp-block-paragraph">You have the option of overriding a column&#8217;s type on the Zoho side. Use it sparingly. Zoho&#8217;s requirement is that the type stays compatible with Redshift&#8217;s, and &#8220;compatible&#8221; is doing quiet work in that sentence: an override that works today breaks the next sync when a value arrives that the Zoho type cannot hold. Changing it in both places, as Zoho recommends, is the version that keeps working.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Family 3: time</h2>



<p class="wp-block-paragraph">This one does not appear as a mismatch anywhere. It appears as a reconciliation problem, which is worse, because you spend the first hour looking for missing rows.</p>



<p class="wp-block-paragraph">Redshift has <code>TIMESTAMP</code>, which carries no timezone and means whatever the writer intended, and <code>TIMESTAMPTZ</code>, which is stored in UTC. A BI tool has a timezone setting of its own. Between those, a row written at 23:40 local time can be counted on a different day at each end.</p>



<p class="wp-block-paragraph">The tell is specific and worth memorising: <strong>daily totals match, monthly totals do not.</strong> Nothing is missing. A few hours&#8217; worth of rows at each month boundary are being attributed to the neighbouring month.</p>



<pre class="wp-block-code"><code>-- Run the dashboard's aggregate directly against Redshift and
-- compare. Relative bounds so this keeps working next month.
SELECT DATE_TRUNC('day', created_at) AS day,
       COUNT(*)                      AS orders,
       SUM(total_amount)             AS revenue
FROM analytics.orders
WHERE created_at &gt;= DATEADD(month, -1, DATE_TRUNC('month', GETDATE()))
  AND created_at &lt;  DATE_TRUNC('month', GETDATE())
GROUP BY 1
ORDER BY 1;</code></pre>



<p class="wp-block-paragraph">The fix is to stop making the BI tool guess. Convert in Redshift, expose both the UTC instant and a pre-computed local date, and build every report on the pre-computed one:</p>



<pre class="wp-block-code"><code>-- CONVERT_TIMEZONE is the Redshift idiom. Doing this once here
-- beats doing it in every report and getting it right in most.
SELECT
    created_at                                            AS created_at_utc,
    CONVERT_TIMEZONE('UTC', 'Europe/London', created_at)  AS created_at_local,
    CAST(CONVERT_TIMEZONE('UTC', 'Europe/London', created_at) AS DATE)
                                                          AS order_date_local
FROM analytics.orders;</code></pre>



<p class="wp-block-paragraph">Named zones rather than fixed offsets, so daylight saving is handled for you. A hardcoded offset is correct for roughly half the year.</p>



<h2 class="wp-block-heading">The structural fix: give Zoho a contract, not your tables</h2>



<p class="wp-block-paragraph">Everything above is treatment. This is prevention, and it is the part worth doing even if nothing is currently broken.</p>



<p class="wp-block-paragraph">Pointing a BI tool at base tables means every upstream change is a potential BI incident. Someone widening a column, renaming a field, or adding a <code>SUPER</code> column for a new feature has no idea a dashboard depends on it. Put a view layer in between and that stops being true: the view is the interface, the tables underneath are free to change, and you decide when the interface changes.</p>



<pre class="wp-block-code"><code>CREATE OR REPLACE VIEW analytics.v_orders_bi AS
SELECT
    -- Explicit casts pin the types Zoho will see, so an upstream
    -- change cannot quietly alter the shape of the report.
    CAST(o.order_id      AS BIGINT)         AS order_id,
    CAST(o.order_status  AS VARCHAR(64))    AS order_status,
    CAST(o.total_amount  AS DECIMAL(18,2))  AS total_amount,

    o.created_at                            AS created_at_utc,
    CAST(CONVERT_TIMEZONE('UTC','Europe/London', o.created_at) AS DATE)
                                            AS order_date_local,

    -- SUPER flattened to something a BI column can hold.
    CAST(o.attributes.channel AS VARCHAR(64)) AS channel
FROM analytics.orders o
WITH NO SCHEMA BINDING;</code></pre>



<p class="wp-block-paragraph"><code>WITH NO SCHEMA BINDING</code> creates a late-binding view: it does not hold a dependency on the underlying table, so you can drop and recreate <code>analytics.orders</code> without Redshift refusing or the view vanishing. For a warehouse where tables get rebuilt by a nightly load, that is the difference between a maintenance window and a broken dashboard.</p>



<p class="wp-block-paragraph">List columns explicitly. Never <code>SELECT *</code> in a view a BI tool depends on, because then any upstream column addition changes the contract without anybody deciding to.</p>



<p class="wp-block-paragraph">Give the connection its own read-only Redshift user, granted access to the views and nothing else. That also means the credentials in the BI tool cannot read tables you did not intend to publish:</p>



<pre class="wp-block-code"><code>CREATE USER zoho_reader PASSWORD 'use-a-generated-one';
GRANT USAGE ON SCHEMA analytics TO zoho_reader;
GRANT SELECT ON analytics.v_orders_bi TO zoho_reader;</code></pre>



<p class="wp-block-paragraph">The honest cost: a view layer is a thing to maintain, and adding a column now means editing the view as well as the table. That is the point. The friction is the control. If your views are getting numerous, managing them with dbt or an equivalent gives you version control and review on what is otherwise a pile of undocumented SQL.</p>



<h2 class="wp-block-heading">A repeatable resolution procedure</h2>



<ol class="wp-block-list">
<li><strong>Get ground truth from Redshift.</strong> Run the <code>SVV_COLUMNS</code> query and save the output.</li>
<li><strong>Open the Mismatch tab</strong> in the connection settings and list every entry, including ones on tables nobody reports on.</li>
<li><strong>Resolve every mismatch.</strong> All of them. A single leftover blocks design sync for everything else.</li>
<li><strong>Trigger Sync Design</strong> and confirm the new columns actually appear before moving on.</li>
<li><strong>Check types, not just names.</strong> A column can sync successfully and still be the wrong type. Spot-check the ones carrying money and dates.</li>
<li><strong>Reconcile a known number.</strong> Run the same aggregate in both places for a closed period. Daily and monthly. If daily matches and monthly does not, go back to the timezone section.</li>
<li><strong>Fix broken reports last</strong>, once the data underneath them is right.</li>
</ol>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Sync Design runs but the new column never appears</h3>



<p class="wp-block-paragraph">An unresolved mismatch is blocking it, almost certainly on a table you were not looking at. Clear the Mismatch tab completely, then sync again.</p>



<h3 class="wp-block-heading">&#8220;This view cannot be accessed due to some changes made in the table&#8221;</h3>



<p class="wp-block-paragraph">Something the report depends on was renamed or deleted in Redshift. Re-synchronise that table from the connection settings. If it recurs after every deployment, that is the argument for the view layer.</p>



<h3 class="wp-block-heading">The connection failed entirely</h3>



<p class="wp-block-paragraph">Check three things in order: whether the Redshift database was renamed or dropped, whether Zoho&#8217;s IP addresses are still allowlisted in your security group, and whether the credentials still work. A renamed database needs the connection edited; a dropped one means starting over.</p>



<h3 class="wp-block-heading">Reports show old data after a schema fix</h3>



<p class="wp-block-paragraph">In Live Connect, caching can be enabled per workspace with its own refresh interval, and it applies to reports rather than tables. If the numbers look stale after you fixed something, check that setting before you conclude the fix did not work.</p>



<h3 class="wp-block-heading">Text is truncated in the dashboard but complete in Redshift</h3>



<p class="wp-block-paragraph">Byte-length truncation on multi-byte characters, or a <code>TEXT</code> column that quietly became <code>VARCHAR(256)</code>. Compare <code>LENGTH</code> against <code>OCTET_LENGTH</code> and widen the column.</p>



<h3 class="wp-block-heading">Totals are close but not equal</h3>



<p class="wp-block-paragraph">Timezone if the gap sits at period boundaries. Precision if it is a consistent tiny drift across everything. Filters that differ between the report and your reconciliation query if it is neither. Check them in that order, because the first two are far more common than a genuinely missing row.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Fixing one mismatch, running Sync Design, and assuming the rest can wait.</li>
<li>Not knowing whether the connection is Data Import or Live Connect, and applying advice for the wrong one.</li>
<li>Overriding a column type in Zoho without changing it in Redshift, so the next sync fails.</li>
<li>Using <code>TEXT</code> in Redshift and expecting PostgreSQL behaviour.</li>
<li>Sizing <code>VARCHAR</code> by character count when the limit is in bytes.</li>
<li>Storing currency as a float.</li>
<li>Mixing quoted and unquoted identifiers, so some column names are case-sensitive and some are not.</li>
<li>Letting reports use raw timestamps and setting the timezone per report.</li>
<li>Using a fixed UTC offset instead of a named timezone.</li>
<li>Pointing the BI tool at base tables that a nightly job rebuilds.</li>
<li><code>SELECT *</code> in a view that a dashboard depends on.</li>
<li>Connecting with an admin-level Redshift user because it was quicker.</li>
<li>Declaring the fix done without reconciling a number against the warehouse.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Expose late-binding views to the BI tool, never base tables.</li>
<li>Cast every column explicitly in the view so the types are decided, not inferred.</li>
<li>Do timezone conversion in Redshift and publish a pre-computed local date.</li>
<li>Lowercase, underscore-separated identifiers everywhere, unquoted.</li>
<li>Fixed-scale <code>DECIMAL</code> for money, never floating point.</li>
<li>Size <code>VARCHAR</code> against <code>OCTET_LENGTH</code> of real data, with headroom.</li>
<li>A dedicated read-only Redshift user granted access only to the reporting views.</li>
<li>Treat the Mismatch tab as a queue to empty, not a list to triage.</li>
<li>Reconcile at least one aggregate against the warehouse after every schema change.</li>
<li>Version-control the view definitions, with dbt or just a repository of SQL files.</li>
<li>Tell whoever owns the upstream tables that a view depends on them.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What exactly is a mismatch in Zoho Analytics?</h3>



<p class="wp-block-paragraph">A disagreement between the table and column names Zoho Analytics expects and the ones Redshift currently has. They are listed in the Mismatch tab of the Redshift connection settings. The important property is that leaving one unresolved stops Sync Design from fetching new column information at all.</p>



<h3 class="wp-block-heading">Why does Sync Design not pick up my new column?</h3>



<p class="wp-block-paragraph">Because there is at least one mismatch outstanding. Clear the Mismatch tab entirely and run it again. It is not a caching issue and re-running it more times will not help.</p>



<h3 class="wp-block-heading">Should I use Data Import or Live Connect?</h3>



<p class="wp-block-paragraph">Data Import when you want fast dashboards, query tables, and the ability to blend data, and can accept the data being as fresh as the last sync. Live Connect when the numbers must be current and you would rather not duplicate the data, accepting that report speed becomes a Redshift performance question and every schema change needs a manual sync.</p>



<h3 class="wp-block-heading">Can I change a column&#8217;s data type in Zoho Analytics?</h3>



<p class="wp-block-paragraph">In Data Import, yes, provided it stays compatible with the Redshift type. In Live Connect, no. Either way the durable fix is to cast the column correctly in a view on the Redshift side, so both ends agree without anyone having to remember an override exists.</p>



<h3 class="wp-block-heading">Why do my dashboard totals not match the warehouse?</h3>



<p class="wp-block-paragraph">If daily figures agree and monthly ones do not, it is timezone handling at period boundaries. If everything is off by a tiny consistent amount, it is numeric precision. Genuinely missing rows are the least likely of the three and the one people check first.</p>



<h3 class="wp-block-heading">How do I handle SUPER columns?</h3>



<p class="wp-block-paragraph">Do not expose them. Extract the specific fields you report on, cast them to scalar types in a view, and let the rest stay in the warehouse. A BI tool has nowhere to put a nested document.</p>



<h3 class="wp-block-heading">Will a view layer slow down Live Connect reports?</h3>



<p class="wp-block-paragraph">A little, since the casts and conversions run per query. In practice the dominant cost is how much data the query scans, so sort keys and distribution keys on the underlying tables matter far more than the view. Measure before optimising, and if a particular view is genuinely expensive, materialise it as a table refreshed by your load job.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">A Zoho Analytics Redshift schema mismatch is not a small isolated problem you can leave in the queue. One unresolved entry stops new columns arriving at all, which is why the symptom people report is almost never &#8220;there&#8217;s a mismatch&#8221; and almost always &#8220;I added a column and nothing happened&#8221;.</p>



<p class="wp-block-paragraph">Clear them all, then sync, then reconcile a real number rather than trusting that it worked. And once it is working, spend the afternoon putting a view layer in between, because the alternative is having this conversation again the next time someone upstream renames a field they had no idea you were reading.</p>



<h2 class="wp-block-heading">Need this sorted out properly?</h2>



<p class="wp-block-paragraph">Warehouse-to-BI connections tend to be set up once, by someone who has since moved on, and then quietly degrade. Work I take on:</p>



<ul class="wp-block-list">
<li>Auditing an existing Redshift to Zoho Analytics connection and clearing the mismatch backlog properly.</li>
<li>Building a reporting view layer in Redshift with explicit casts, timezone handling and late binding, so upstream changes stop breaking dashboards.</li>
<li>Reconciling dashboard figures against the warehouse and finding where the difference comes from.</li>
<li>Redshift schema work: type corrections, column sizing, flattening <code>SUPER</code> data into reportable columns.</li>
<li>Least-privilege database users and network access for BI tools, including security group and allowlist configuration.</li>
<li>Putting the view definitions under version control with dbt so schema changes get reviewed instead of discovered.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of the <code>SVV_COLUMNS</code> query above and a screenshot of your Mismatch tab, and I will tell you what is actually wrong.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/zoho-analytics-redshift-schema-mismatch/">The Column That Never Shows Up: Fixing Redshift and Zoho Analytics Schema Mismatches</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/technical-guides/zoho-analytics-redshift-schema-mismatch/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Connect Amazon Redshift to Zoho Analytics Without Putting Your Warehouse on the Internet</title>
		<link>https://john-nessime.com/blog/web-security/connect-redshift-zoho-analytics/</link>
					<comments>https://john-nessime.com/blog/web-security/connect-redshift-zoho-analytics/#respond</comments>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 02 Aug 2026 07:59:30 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Amazon Redshift]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPC]]></category>
		<category><![CDATA[Zoho Analytics]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=80</guid>

					<description><![CDATA[<p>Every guide tells you to tick "Publicly Accessible" and move on. That works, and it also puts your warehouse on the internet with whatever credentials were to hand. Here's the full setup done deliberately: least-privilege user, narrow network access, and the outbound-only option nobody mentions.</p>
<p>The post <a href="https://john-nessime.com/blog/web-security/connect-redshift-zoho-analytics/">Connect Amazon Redshift to Zoho Analytics Without Putting Your Warehouse on the Internet</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You fill in the connection form, click test, and it sits there until it times out. You search the error. Every result says the same thing: go into the Redshift console and tick <strong>Publicly Accessible</strong>. You do it. The connection succeeds. Dashboards start working and everyone moves on.</p>



<p class="wp-block-paragraph">What just happened is that your data warehouse now has an endpoint on the public internet, reachable on port 5439, and a third-party SaaS platform holds credentials to it. Usually those credentials belong to whatever user was to hand, which on a lot of clusters means the admin account that can read every schema you have.</p>



<p class="wp-block-paragraph">That is not an argument against doing it. It is an argument for doing it on purpose. Most guides on how to <strong>connect Amazon Redshift to Zoho Analytics</strong> walk you through six screenshots and never mention the two decisions that actually matter: which user this connection runs as, and how much of your network it needs.</p>



<p class="wp-block-paragraph">This covers the whole setup in order, with those decisions made deliberately: the mode you pick and why it is hard to change later, a read-only user scoped to almost nothing, network access done narrowly, and what to actually expose once it works.</p>



<h2 class="wp-block-heading">What the connection actually requires</h2>



<p class="wp-block-paragraph">Strip away the interface and the mechanics are simple. Zoho Analytics runs on Zoho&#8217;s infrastructure. To reach your cluster it opens an inbound TCP connection from Zoho&#8217;s own IP addresses to your Redshift endpoint, on the Redshift port. That is it.</p>



<p class="wp-block-paragraph">Which means three things have to be true, and they are the three places setup fails:</p>



<ul class="wp-block-list">
<li><strong>The endpoint has to be reachable from outside your VPC.</strong> A cluster in a private subnet with no public endpoint cannot be reached by a SaaS tool, full stop.</li>
<li><strong>The security group has to allow it.</strong> Zoho publishes the IP addresses it connects from, and those need an inbound rule on the Redshift port. The default port is 5439, though it can be changed at cluster creation, so check yours rather than assuming.</li>
<li><strong>The credentials have to work</strong> and the user has to be able to see the schemas you want to report on.</li>
</ul>



<p class="wp-block-paragraph">Zoho keeps the current allowlist on a dedicated help page. Do not copy a list of addresses out of a blog post, including this one. They change, and a stale allowlist produces an intermittent failure that looks like everything except what it is.</p>



<h2 class="wp-block-heading">Decide the mode before you start</h2>



<p class="wp-block-paragraph">Zoho offers two fundamentally different connection types, and switching later means rebuilding the workspace. Get this one right the first time.</p>



<p class="wp-block-paragraph"><strong>Data Import</strong> copies your data into Zoho Analytics on a schedule and reports run against their storage. Dashboards are fast, you can create query tables, and you can import a filtered subset using a custom query. Column additions and deletions sync automatically. The trade is freshness: your numbers are as current as the last sync.</p>



<p class="wp-block-paragraph"><strong>Live Connect</strong> stores nothing and queries Redshift each time a report loads. Numbers are always current and your data does not leave AWS. It is available on the paid tiers only. The trade is bigger than it looks: report speed becomes a Redshift performance problem, schema changes need a manual sync each time, you cannot change column types on the Zoho side at all, and no query tables.</p>



<p class="wp-block-paragraph">My default for most teams is Data Import, because the operational surface is smaller and a scheduled sync is usually fresh enough for a dashboard people look at once a day. Live Connect earns its cost when the numbers genuinely need to be current, or when duplicating the data into a third-party system is a compliance problem.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 1: create a user that cannot do much</h2>



<p class="wp-block-paragraph">Do this before you open any network access, so that when the connection works you already know its blast radius.</p>



<pre class="wp-block-code"><code>-- A dedicated user for this connection and nothing else.
CREATE USER zoho_reader PASSWORD 'generate-a-long-random-one';

-- Reach the schema, then read the objects in it.
-- Both grants are needed; USAGE alone gets you nothing.
GRANT USAGE ON SCHEMA analytics TO zoho_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO zoho_reader;

-- The grant above covers what exists today. This covers what
-- gets created tomorrow, which is the part people forget and
-- then debug as a mysterious missing table three months later.
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics
  GRANT SELECT ON TABLES TO zoho_reader;</code></pre>



<p class="wp-block-paragraph">Then check what you actually granted, rather than trusting that the statements did what you meant:</p>



<pre class="wp-block-code"><code>SELECT table_schema, table_name, privilege_type
FROM information_schema.table_privileges
WHERE grantee = 'zoho_reader'
ORDER BY table_schema, table_name;</code></pre>



<p class="wp-block-paragraph">If that returns rows from schemas you did not intend to publish, fix it now. Once the connection is live, someone will build a report on whatever is visible and removing access becomes a conversation instead of a command.</p>



<p class="wp-block-paragraph">The password goes into a SaaS platform, so treat it accordingly: long, random, generated, stored in your password manager, and rotated when people leave.</p>



<h2 class="wp-block-heading">Step 2: open exactly as much network as you need</h2>



<p class="wp-block-paragraph">First find out what your cluster currently looks like, because the console tells you less at a glance than this does:</p>



<pre class="wp-block-code"><code>aws redshift describe-clusters 
  --cluster-identifier my-warehouse 
  --query 'Clusters[0].{Endpoint:Endpoint.Address,Port:Endpoint.Port,Public:PubliclyAccessible,SecurityGroups:VpcSecurityGroups}'</code></pre>



<p class="wp-block-paragraph">That gives you the hostname Zoho needs, the real port, whether the cluster is currently public, and which security groups govern it. Redshift Serverless has an equivalent setting on the workgroup rather than a cluster, but the same reasoning applies.</p>



<p class="wp-block-paragraph">Then add one narrow inbound rule per Zoho range. Not <code>0.0.0.0/0</code>, which is what happens when someone is in a hurry and it never gets tightened afterwards:</p>



<pre class="wp-block-code"><code>aws ec2 authorize-security-group-ingress 
  --group-id sg-0123456789abcdef0 
  --protocol tcp 
  --port 5439 
  --cidr 203.0.113.0/24</code></pre>



<p class="wp-block-paragraph">Put a description on each rule in the console so that in a year somebody can tell which ranges belong to which vendor. An undocumented allow rule is one nobody will ever dare remove.</p>



<p class="wp-block-paragraph">While you are in the cluster parameter group, turn on <code>require_ssl</code> so connections that do not use TLS are refused rather than merely discouraged. Parameter group changes need a cluster reboot, so do it in a window rather than mid-afternoon.</p>



<h3 class="wp-block-heading">Test from outside before you touch Zoho</h3>



<pre class="wp-block-code"><code># Does anything answer on the port, from a machine outside your VPC?
nc -zv my-warehouse.abc123.eu-west-1.redshift.amazonaws.com 5439

# Then prove the credentials work, over TLS, as the new user.
psql "host=my-warehouse.abc123.eu-west-1.redshift.amazonaws.com 
      port=5439 dbname=analytics user=zoho_reader sslmode=require"</code></pre>



<p class="wp-block-paragraph">If both of those work from your laptop, the remaining variable is Zoho&#8217;s IP ranges rather than anything structural. If the first one hangs, it is the security group or public accessibility. If it connects but authentication fails, it is the user. Separating those two takes thirty seconds and saves an hour of guessing inside a web form that reports one generic error for both.</p>



<h3 class="wp-block-heading">If you cannot make the cluster public</h3>



<p class="wp-block-paragraph">Plenty of organisations have a policy against it, and that policy is not unreasonable. Zoho&#8217;s answer for databases behind a firewall is Zoho Databridge, a lightweight agent you install on a machine inside your own network. It opens an <em>outbound</em> connection on 443 to Zoho and waits for requests, so nothing inbound has to be allowed at all. It runs on Windows, macOS and Linux, and it is designed for exactly this situation.</p>



<p class="wp-block-paragraph">Worth checking before you commit to it: Databridge is documented against a long list of relational databases, and Redshift is not always named explicitly in that list. Since Redshift speaks the PostgreSQL wire protocol and has a JDBC driver, it is usually workable, but confirm with Zoho support for your plan rather than taking my word for it. If it fits, an outbound-only agent is a meaningfully better architecture than a public endpoint, and almost nobody setting this up knows the option exists.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 3: make the connection</h2>



<p class="wp-block-paragraph">With the groundwork done, this part is genuinely a form. In Zoho Analytics, create a workspace, choose to import from or connect to Amazon Redshift, and supply the endpoint hostname, the port, the database name, and the <code>zoho_reader</code> credentials.</p>



<p class="wp-block-paragraph">Two expectations worth setting so you do not diagnose a non-problem:</p>



<ul class="wp-block-list">
<li><strong>The first fetch takes a while</strong>, depending on volume and how fast your cluster responds. Zoho emails you when it finishes.</li>
<li><strong>An empty workspace before that fetch completes is normal.</strong> If you open it early it will show nothing, which looks exactly like a failure and is not one.</li>
</ul>



<h2 class="wp-block-heading">Step 4: choose what to expose, carefully</h2>



<p class="wp-block-paragraph">This is the screen people click through fastest and the one with the longest consequences. Whatever tables you select here become the interface between your warehouse and your dashboards, and every future upstream change becomes a potential BI incident.</p>



<p class="wp-block-paragraph">Select views, not base tables. A reporting view with explicit casts, timezone conversion done in Redshift, and semi-structured columns already flattened gives you a stable contract. The tables underneath stay free to change.</p>



<pre class="wp-block-code"><code>CREATE OR REPLACE VIEW analytics.v_orders_bi AS
SELECT
    CAST(order_id     AS BIGINT)        AS order_id,
    CAST(order_status AS VARCHAR(64))   AS order_status,
    CAST(total_amount AS DECIMAL(18,2)) AS total_amount,
    created_at                          AS created_at_utc,
    CAST(CONVERT_TIMEZONE('UTC','Europe/London', created_at) AS DATE)
                                        AS order_date_local
FROM analytics.orders
WITH NO SCHEMA BINDING;</code></pre>



<p class="wp-block-paragraph"><code>WITH NO SCHEMA BINDING</code> makes it a late-binding view, so a nightly job can drop and rebuild the underlying table without Redshift refusing or the view disappearing. On a warehouse with a rebuild-style load, that alone prevents a recurring class of broken-dashboard morning.</p>



<p class="wp-block-paragraph">Grant your reader access to the views specifically, and skip the blanket grant on the whole schema if you can. Fewer objects visible means fewer things somebody can accidentally build a report on.</p>



<h2 class="wp-block-heading">Step 5: schedule it, then verify a real number</h2>



<p class="wp-block-paragraph">In Data Import mode, set the sync schedule to match how the data is actually produced. Syncing hourly when the warehouse loads once at 03:00 just means twenty-three pointless queries a day against your cluster, and Redshift bills by uptime and workload.</p>



<p class="wp-block-paragraph">Then do the step everyone skips. Pick a closed period, run the same aggregate in both places, and compare:</p>



<pre class="wp-block-code"><code>SELECT DATE_TRUNC('day', created_at) AS day,
       COUNT(*)                      AS orders,
       SUM(total_amount)             AS revenue
FROM analytics.v_orders_bi
WHERE created_at_utc &gt;= DATEADD(month, -1, DATE_TRUNC('month', GETDATE()))
  AND created_at_utc &lt;  DATE_TRUNC('month', GETDATE())
GROUP BY 1
ORDER BY 1;</code></pre>



<p class="wp-block-paragraph">A connection that returns data is not the same as a connection that returns correct data. If daily figures agree but monthly ones do not, you have a timezone problem rather than a connection problem, and it is far easier to find now than six weeks into someone&#8217;s board pack.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Connection times out</h3>



<p class="wp-block-paragraph">Network, not credentials. A timeout means nothing answered. Check public accessibility, then the security group rule, then whether Zoho&#8217;s allowlist has changed since you configured it. A refused connection rather than a timeout usually means you reached the right host on the wrong port.</p>



<h3 class="wp-block-heading">Authentication failed</h3>



<p class="wp-block-paragraph">The network is fine, which is genuine progress. Test the same credentials with <code>psql</code> from your own machine. Also check the database name: Redshift clusters often have both a default database and the one you actually use, and connecting to the wrong one authenticates fine and then shows you nothing.</p>



<h3 class="wp-block-heading">Connected, but no tables listed</h3>



<p class="wp-block-paragraph">A permissions gap. <code>USAGE</code> on the schema and <code>SELECT</code> on the objects are separate grants and you need both. Run the <code>information_schema.table_privileges</code> query as a check rather than re-running the grants and hoping.</p>



<h3 class="wp-block-heading">Worked on setup, fails intermittently later</h3>



<p class="wp-block-paragraph">Classic symptom of a partially stale IP allowlist: some of Zoho&#8217;s ranges reach you and some do not, so syncs succeed or fail depending on which host tries. Re-check the published list against your security group rules.</p>



<h3 class="wp-block-heading">Sync succeeds but a new column never appears</h3>



<p class="wp-block-paragraph">In Live Connect, schema changes need a manual Sync Design, and an unresolved mismatch anywhere in the connection stops it fetching new column information at all. Clear the Mismatch tab completely, then sync again.</p>



<h3 class="wp-block-heading">Reports are slow</h3>



<p class="wp-block-paragraph">In Live Connect that is your cluster, not Zoho. Look at the queries hitting Redshift and at sort and distribution keys on the underlying tables. Zoho also offers per-workspace caching for Live Connect with a configurable refresh interval, which trades freshness for speed.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Connecting with the cluster admin user because it was already in the password manager.</li>
<li>Opening the Redshift port to <code>0.0.0.0/0</code> to make the test pass, then never tightening it.</li>
<li>Copying an IP allowlist out of a blog post instead of Zoho&#8217;s own page.</li>
<li>Granting <code>USAGE</code> without <code>SELECT</code>, or the reverse, and concluding the connector is broken.</li>
<li>Forgetting <code>ALTER DEFAULT PRIVILEGES</code>, so tables created later are invisible.</li>
<li>Pointing the connection at base tables rather than reporting views.</li>
<li>Picking Live Connect for the freshness without accounting for the manual sync on every schema change.</li>
<li>Assuming the setup failed because the workspace is empty during the initial fetch.</li>
<li>Scheduling syncs far more often than the warehouse is actually loaded.</li>
<li>Not enforcing TLS on the cluster.</li>
<li>Declaring it done without reconciling a single number against Redshift.</li>
<li>Leaving no note anywhere about which security group rules belong to which vendor.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>A dedicated read-only user per integration, never a shared or admin account.</li>
<li>Grant access to reporting views only, not whole schemas, where you can.</li>
<li>One narrow security group rule per vendor range, described and dated.</li>
<li>Enforce TLS with <code>require_ssl</code> rather than trusting the client to ask for it.</li>
<li>Prefer an outbound-only agent over a public endpoint if your plan and setup support it.</li>
<li>Decide Data Import versus Live Connect deliberately, because changing it means rebuilding.</li>
<li>Expose late-binding views with explicit casts and timezone conversion done in Redshift.</li>
<li>Match the sync schedule to your load schedule, not to how fresh you wish the data were.</li>
<li>Reconcile at least one aggregate before anyone builds a dashboard on it.</li>
<li>Rotate the connection password when people leave, and document where it lives.</li>
<li>Test connectivity and credentials separately, from outside the VPC, before blaming the connector.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Does Redshift have to be publicly accessible?</h3>



<p class="wp-block-paragraph">For a direct connection, yes: Zoho reaches your cluster inbound from its own IP addresses, so there has to be an endpoint it can resolve and reach. The alternative is Zoho Databridge, an agent inside your network that connects outbound on 443, which removes the inbound requirement entirely. Confirm Redshift support for it with Zoho before planning around it.</p>



<h3 class="wp-block-heading">Which port does Zoho need open?</h3>



<p class="wp-block-paragraph">Whichever port your cluster listens on. 5439 is the Redshift default, but it can be set to something else when the cluster is created, so read it from <code>describe-clusters</code> rather than assuming.</p>



<h3 class="wp-block-heading">Should I use Data Import or Live Connect?</h3>



<p class="wp-block-paragraph">Data Import for most cases: faster dashboards, query tables, automatic column syncing, and a smaller operational surface. Live Connect when the numbers must be current or copying data into a third party is a compliance issue, accepting slower reports and manual schema syncs. Decide before you build, because switching means starting the workspace over.</p>



<h3 class="wp-block-heading">What permissions does the Zoho user need?</h3>



<p class="wp-block-paragraph"><code>USAGE</code> on the schema and <code>SELECT</code> on the objects you want reported on. Nothing else. Add <code>ALTER DEFAULT PRIVILEGES</code> so objects created later are covered without anybody having to remember.</p>



<h3 class="wp-block-heading">Why is the workspace empty after I finish setup?</h3>



<p class="wp-block-paragraph">The initial fetch has not finished. Zoho emails you when it completes, and the workspace shows nothing until then. Give it time before you start pulling the configuration apart.</p>



<h3 class="wp-block-heading">Can I limit which tables Zoho can see?</h3>



<p class="wp-block-paragraph">Yes, and you should. Grant the reader access only to a set of reporting views in a dedicated schema. That is a stronger control than the table picker in the interface, because it holds even if somebody later edits the connection.</p>



<h3 class="wp-block-heading">Will this increase my Redshift bill?</h3>



<p class="wp-block-paragraph">Live Connect can, since every report load is a query. Data Import costs you one scheduled query run per sync. If cost matters, Data Import on a schedule matched to your load window is the cheaper shape by a wide margin.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Getting Zoho Analytics to talk to Redshift takes about twenty minutes. Getting it to talk to Redshift without handing a SaaS platform a superuser account and an open port takes about an hour, and that hour is the entire difference between a connection you can defend in an audit and one you quietly hope nobody asks about.</p>



<p class="wp-block-paragraph">So do it in this order: least-privilege user first, narrow network access second, connection third, views rather than tables fourth, and a reconciled number before anyone builds a dashboard on it. Every step after the first is easier when the first one is already done.</p>



<h2 class="wp-block-heading">Want this set up properly the first time?</h2>



<p class="wp-block-paragraph">Most of these connections get built under time pressure and inherit whatever shortcuts made the test pass. Work I take on:</p>



<ul class="wp-block-list">
<li>Setting up a Redshift to Zoho Analytics connection end to end, with least-privilege credentials and scoped network access.</li>
<li>Reviewing an existing connection and reporting what it can actually reach, then narrowing it without breaking reports.</li>
<li>Building the reporting view layer in Redshift: explicit casts, timezone handling, flattened semi-structured columns, late binding.</li>
<li>Network architecture for BI access, including outbound-agent options where a public endpoint is not acceptable.</li>
<li>Choosing between Data Import and Live Connect based on your actual freshness, cost and compliance constraints.</li>
<li>Reconciliation checks so dashboard numbers are verified against the warehouse rather than assumed.</li>
</ul>



<p class="wp-block-paragraph">Tell me whether your cluster is currently public and which user the connection runs as, and I will tell you what I would change first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/web-security/connect-redshift-zoho-analytics/">Connect Amazon Redshift to Zoho Analytics Without Putting Your Warehouse on the Internet</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://john-nessime.com/blog/web-security/connect-redshift-zoho-analytics/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
