You open the dashboard. Revenue for Q3 is displayed — but it's half of what you expected. Refresh the page. Now it's double. Refresh again. Different number. At this point, you might as well shake a Magic 8-Ball.
That sinking feeling — when your data warehouse reports contradict themselves — is more common than most teams admit. The problem usually isn't the database engine or the BI tool. It's something in between: a misconfigured incremental load, a row-level security filter that's too aggressive, or a semantic layer that's mapping a column to the wrong field. This guide is for the person staring at a report, trying to decide whether to fix the code or just walk away. We'll prioritize fixes by impact, so you can stop guessing and start trusting your numbers again.
Why Your Warehouse Reports Lie (And Why It's Not the Warehouse)
The real cost of untrusted data — lost decisions, wasted hours
I walked into a Monday morning standup once where the entire product team had spent the weekend debating whether daily active users had dropped 12% or jumped 22%. Both numbers came from the same warehouse. Same query, same dashboard, different hours. Nobody shipped anything that week. The trust just evaporated. That's the real cost of report inconsistency: not a wrong number, but a paralyzed team that stops making decisions. You waste hours in meetings arguing about which version of reality is true. Worse, you start treating every chart like a Magic 8-Ball — fun to shake, impossible to bet on. The financial damage compounds fast. A delayed product launch, a misallocated marketing budget, a missed inventory warning — each one traces back to someone who looked at a report and shrugged.
Common false suspects: BI tool, database performance, user error
The knee-jerk reaction is almost always wrong. I have seen engineering teams spend three weeks tuning Snowflake warehouses that were barely at 20% utilization. They blamed the database. Others blamed Power BI's caching layer or Tableau's extract refresh schedule. Wrong target. The BI tool merely renders what it receives. The database just stores bits. And user error — sure, someone might misclick a filter, but not four different stakeholders on four different dashboards simultaneously. The pattern is too consistent for incompetence. The real culprit lives upstream: the pipeline that feeds the warehouse, or the semantic layer that translates raw tables into business concepts. That's where the lies originate.
Every report that contradicts itself is actually telling you one honest thing — the truth lives somewhere upstream, not inside the warehouse.
— paraphrased from a data architect who rebuilt his entire pipeline after one too many Monday panics
Most teams skip this diagnostic step entirely. They see a discrepancy and immediately deep-dive into the report's SQL. But SQL is the last place to check — it's the end of the chain, not the beginning.
The one question that always pinpoints the root cause
Ask this single question in your next triage: Does the number change if you run the same report at a different time of day? Yes? Then you have a pipeline timing issue — data freshness or incremental load boundaries. No? Then look at access controls or dimension drift. That question alone eliminates half the debugging space. The catch is that most teams never ask it. They run the report once, get a weird number, and start tearing apart the fact table joins. That hurts. I have fixed exactly one warehouse hardware problem in six years of doing this. Everything else was a pipeline bug, a bad merge, or a semantic layer mismatch. So stop blaming the warehouse. It's almost never the warehouse. The warehouse is just the innocent middleman carrying bad news from upstream. Fix the pipeline, fix the trust.
The First Thing to Check: Data Freshness
How Stale Data Creates Phantom Discrepancies
You compare two dashboards. One shows yesterday’s revenue. The other shows a number that’s 12% lower. Your first instinct is a bad join, a corrupted fact table, or—worst case—someone silently changed a business rule. Nine times out of ten, it’s none of those. It’s just old data. One report ran at 6:00 AM against last night’s full load. The other runs on-demand against a source that hasn’t synced since Tuesday. Neither is wrong. They’re just living in different time zones inside your own warehouse. That’s the phantom. It vanishes the moment you check timestamps.
Timestamp Audits Across Source, Staging, and Presentation Layers
Most teams skip this: they check the presentation layer timestamp—the “last refreshed” label on the dashboard—and call it done. That label only tells you when the report rendered, not when the underlying data was current. The real audit needs three timestamps. Source system commit time. Staging table load time. Final fact-table watermark. I have seen a warehouse report claim “data as of 15 minutes ago” while the staging layer was still holding a batch from three days prior—the fact table had a newer timestamp because someone ran a refresh against an empty pipeline. Wrong order. Not yet. That hurts.
Here is a concrete pattern: your ETL tool should stamp every row with a dwh_loaded_at column, and your fact table should carry a source_effective_date. When those two diverge by more than your SLA, the report is lying. That’s the seam to watch.
“If your source_effective_date and your report timestamp differ by more than one batch cycle, you aren’t debugging a logic error—you’re debugging a scheduling problem.”
— Engineering lead at a mid-market e-commerce firm, after chasing a phantom revenue leak for three sprints
Not every data checklist earns its ink.
Not every data checklist earns its ink.
A 5-Minute Query to Verify Freshness
I keep one query in my Bookmarks bar. It’s short, ugly, and it has saved me more hours than any data profiling tool. Here is the logic: for each critical table in your warehouse (sales, inventory, user sessions), select the maximum source_effective_date from the fact layer, join it to the maximum dwh_loaded_at from the staging layer, then compute the gap in hours. Filter to gaps larger than your defined batch window—say, 24 hours. Run it every morning. You get a single row per table that screams “stale” or “okay.” The catch: you have to define the batch window table by table. A real-time clickstream table should have a 5-minute window. A weekly finance rollup can tolerate 48 hours. If you lump them together, you normalize the failure. One concrete tweak: add a freshness_class column that buckets tables as ‘near-real-time’, ‘daily’, or ‘weekly’. Then your query can flag violations per class without false alarms.
The trick is running this before the quarterly board meeting, not during. I once saw a team spend three days debugging a slowly changing dimension issue when the real problem was that the source system’s CDC feed had been paused for a maintenance window. The query would have caught it in 45 seconds. Three days. Stale data. Rebuild that habit first.
The Second Thing: Row-Level Security Gone Rogue
How RLS filters silently exclude data for some users but not others
You're looking at a chart that says revenue dropped 12% this week. Your colleague in the next cube is celebrating a 4% uptick. Same warehouse, same table, same timestamp. One of you is wrong—and neither of you is lying. Row-level security (RLS) is usually the culprit. It chews through permissions quietly, applying a WHERE clause you never wrote. Most teams set it up once and forget it. That's the danger. RLS doesn't announce itself. It just… filters. A sales manager sees only her region. Her counterpart sees only his product line. But when someone asks "What is total company revenue?" both answers are incomplete. The warehouse serves exactly what each user is allowed to see. That's correct behavior—until someone builds a dashboard that assumes a global view.
The real pain hits when RLS policies overlap. Imagine a policy that says "user can see rows where region = 'West'" and another policy that says "user can see rows where department = 'Sales'." A user in West Sales still gets a full dataset—both policies pass. But a user in West Engineering? The engine applies an AND between the policies. Suddenly rows from West Finance vanish, silently. The dashboard looks fine to the West Sales team. It looks broken to everyone else. Nobody files a bug because nobody knows what they're missing. That's the insidious part.
Debugging RLS with a no-filter baseline query
Stop guessing. Run a query that bypasses RLS entirely—using a service account or a direct database role that skips policy enforcement. Compare that result to what your normal user accounts see. The gap is your problem. I have done this exact exercise for a mid-market retail client who had been arguing about conversion rates for three months. The no-filter query showed 4,200 transactions. The CEO's account showed 4,200. The regional director's account showed 3,100. The discrepancy? A stale policy that excluded any order where the ship-to state was blank—an edge case that affected exactly 1,100 records. That gap vanished in thirty minutes once we could see it.
The trick is building a baseline query that mirrors your fact table structure without any permission predicates:
- Select the same granularity as your report grain
- Include the same time range and dimension keys
- Do NOT apply any WHERE clause tied to user identity
Run that once a week. Diff it against a real-user query. If the counts diverge by more than 0.5%, your RLS needs surgery. Most monitoring tools check latency or row counts—they never check whether two users see the same data. That's a gap you can close in one afternoon.
Common RLS mistakes: overlapping policies, missing IN clauses, and the WHERE trap
The worst RLS pattern I see is the "one policy to rule them all." A single policy that reads CURRENT_USER() against a lookup table, but uses a subquery that returns NULL when a user isn't found. NULL in a WHERE clause evaluates to UNKNOWN. UNKNOWN means the row is excluded. Zero rows returned. The dashboard shows a blank. The user assumes data hasn't loaded yet. They wait. That hurts.
Another classic mistake: forgetting that RLS policies in Snowflake or Redshift are additive by default. Two policies both apply. If policy A says "region = 'East'" and policy B says "region = 'West'", a user assigned both roles sees nothing—because no single row satisfies both filters simultaneously. The fix is switching to a single policy with an IN clause: WHERE region IN ('East', 'West'). That seems trivial in hindsight. Most teams only catch it after a week of firedrills.
'We spent six hours chasing a 2% discrepancy in monthly active users. Turned out the support team's RLS excluded accounts created on Sundays.'
— a data engineer at a mid-size SaaS company, after a post-mortem I attended
Field note: data plans crack at handoff.
Field note: data plans crack at handoff.
Sunday births. No one thought to check. When you debug RLS, think about the conditions your business logic finds absurd—then check if your policies accidentally encode those conditions. Run the no-filter baseline on a Saturday night. Compare it to Monday morning. If the numbers shift, your security policies are changing the business truth. Patch them. Then pin the query to a monitoring dashboard. That single habit will save you more time than any load-optimization trick ever will.
The Third Thing: Incremental Load Logic Errors
The Watermark Column Trap
Most incremental loads rely on a single timestamp—and that trust gets broken the moment someone picks the wrong column. I once watched a team double-count 40,000 orders because their pipeline used created_at as the watermark. Fine for inserts, catastrophic for updates. Updated_at catches modifications; created_at ignores them entirely. The result? Every re-processing of an order revision added a fresh row to the fact table. The report showed revenue 23% higher than reality. Management celebrated the "growth" for two weeks before the CFO asked why inventory didn't match sales.
How Incremental Loads Duplicate—or Vanish
Here is the exact failure pattern. Your pipeline queries WHERE updated_at > last_run_timestamp. That works beautifully—until a late-arriving transaction appears with a created_at in the past. It gets skipped, permanently. The opposite problem: WHERE created_at > last_run_timestamp misses every retroactive price correction, every status change, every refund adjustment. Most teams skip this: they test with current data only.
There is a subtler killer. Watermark precision mismatches. Your source stores timestamps with milliseconds; your watermark table stores only seconds. A transaction at 14:23:45.123 rounds down to 14:23:45.000—which equals last_run. That record vanishes into the gap. Not yet catastropic—until you run a full refresh and the numbers shift 5% without explanation.
What usually breaks first is the boundary window. Say your pipeline runs every 30 minutes. A record updated during run time gets missed entirely by both the current batch and the next one. The catch is that this looks like a slow ETL, not a data loss. You add retry logic—which doubles the root problem.
“We ran weekly full refreshes and never noticed the daily gap. By month six, the discrepancy was 18% and nobody trusted the dashboard anymore.”
— Former BI lead, mid-market retailer (on why they finally abandoned incremental pure-play)
The Manual Reconciliation That Saves Your Week
Stop guessing. Run a one-time full refresh on the same date range as your last incremental load. Compare row counts per day. Not total—per day. I promise you will spot the divergence inside three calendar days. If incremental shows 1,412 rows for Tuesday and full shows 1,389, your watermark logic has a hole. The fix isn't always obvious—sometimes you need a hash-based dedup step, sometimes you need a watermark that records the batch execution timestamp, not the source record timestamp. Wrong order and the seam blows out again next month.
That hurts worse when your CDC snapshot drifts. But that's a problem for the next section. Right now: pick one fact table, run both methods, compare by grain. If they disagree, patch the watermark first. If they disagree again after patching—you have an ingestion idempotency bug, not a freshness issue. And that means tearing apart the incremental logic entirely. Honest—sometimes the fastest path is a rebuild. But only after you prove the watermark isn't the liar. Most days, it's.
Edge Cases: Time Zones, Surrogate Keys, and CDC Snapshot Drift
Time zones in fact and dimension tables — a quiet chaos
Most teams set their warehouse to UTC and call it done. That sounds fine until a fact table logs order_ts in UTC while the dimension table stores customer_timezone as a raw offset like '-05:00'. Now run a daily report that groups by local date. You get Tuesday orders sliced into Wednesday because daylight saving shifted the offset by an hour. The data is correct. The report is wrong. I have debugged this exact scenario at three different companies — each time the fix was trivial (standardize a tz_database_name column like 'America/New_York') but the damage had already hit a stakeholder call.
Odd bit about warehousing: the dull step fails first.
Odd bit about warehousing: the dull step fails first.
The catch: you can't trust a string offset after a DST transition. Store IANA time-zone names. Join facts to dimensions using a surrogate key that ties the event timestamp to the zone effective at that moment. That means a slowly changing dimension type 2 for time zones — annoying, but less annoying than explaining to finance why margins slumped by $40k on a day that never had a clock change.
“We fixed the time zone in the source system. The warehouse still showed the old offset for three months. Nobody noticed.”
— data engineer, after a 2 AM post-mortem
Surrogate key collisions after restore or rollback
Surrogate keys are supposed to be stable integers. Then a disaster happens — a restore from backup, a partial rollback, a test refresh that bleeds into prod. Suddenly key 104,732 used to mean 'customer from Atlanta' and now it points to a row from the staging environment. Joins don't error. They silently resolve to wrong data. The business sees churn rates that look like a hockey stick. What broke? Nothing broke — the key just drifted.
Most teams skip this because keys usually work. But I have watched a six-hour incremental load pipeline corrupt a year of sales history because a COPY INTO statement reused identity values after a failed transaction. The fix is boring: always include a natural-key hash in your fact rows alongside the surrogate. Then build a validation query that compares count of unique natural keys against count of unique surrogates. Run it every morning. When the numbers diverge — stop and rebuild the affected partitions. Not elegant. But it catches the seam before the report hits the CEO inbox.
CDC snapshot drift when source and target clocks disagree
Change data capture timestamps from the source database. That's gospel — until the source server clock drifts by 47 seconds because of a hypervisor clock-skew bug. Your incremental load uses modified_at > last_watermark. Now every row edited during those 47 seconds either gets missed entirely (watermark too far ahead) or duplicated on the next pull (watermark too far behind). The result is not a crash. It's a slow bleed of phantom rows into monthly aggregates.
The harder variant: CDC snapshots from a log-based system where the commit timestamp comes from the transaction coordinator, not the row’s actual wall clock. That coordinator can lag minutes behind the source database clock under write pressure. You load a snapshot, watermark moves forward, and the next snapshot excludes rows that committed later but have older timestamps. Rows disappear from reports. A real customer issue at a fintech shop I consulted for — it took two weeks to trace because every individual table looked fine. The fix: always use a monotonically increasing log-sequence number (e.g., Oracle SCN or PostgreSQL LSN) alongside the wall clock. Never trust the wall clock alone for CDC watermarks. Document that rule on a sticky note above your monitoring dashboard — it will save your next on-call.
When to Give Up and Rebuild (And When to Patch)
Spot the difference between a scar and a wound
I once spent three weeks patching a marketing funnel report that still showed 14% more conversions than the CRM logged. The client kept asking for 'one more filter tweak.' The real problem? The ingestion layer had been built on a now-retired API schema that truncated NULLs into default channel values. No patch could fix that foundation. You're not fixing a bug — you're propping up a corpse. Signs your pipeline is beyond repair: the root cause lives upstream of your transformation layer, the same logic error appears in three different marts, or your test suite passes but stakeholders still reconcile to different numbers weekly. That last one hurts. It means your data doesn't match reality even when the code looks correct.
The cost of continuing to duct-tape the semantic layer
Patching feels cheaper. It's — for one sprint. Then you stack a CASE statement on top of a UNION on top of a misaligned surrogate key, and suddenly your monthly active user count changes because someone fixed a time-zone offset in staging but not in the legacy view. The semantic layer becomes a haunted house. Every new team member adds a room. I have seen teams spend 40 hours per quarter just re-explaining why 'total revenue' differs between the executive dashboard and the finance export. That's the real cost: lost trust. Nobody asks questions anymore; they just know the reports are wrong.
The rebuild decision comes down to one arithmetic problem. Add the time you have spent in the last six months debugging, documenting workarounds, and re-running failed pipelines. Multiply by two — because you're probably undercounting. If that number exceeds the effort to rebuild the offending mart or pipeline from a clean source, stop patching. Burn it. Start with a star schema and one source of truth for date dimension keys.
Setting SLAs that force honesty instead of false precision
Every team I have worked with promises 'real-time data.' Then they load incrementally every 30 minutes and wonder why the 9:04 AM report misses the 9:02 AM transaction. The fix is not faster pipelines. The fix is an SLA that says: 'Data is current as of 45 minutes ago, except for X source which lags by 2 hours.' Write that on the dashboard header. In bold. It changes everything. Users stop treating reports like magic 8-balls because you told them what the ball actually knows.
‘We spent a year patching a revenue report that recalculated differently each morning. Rebuilding from the raw ingestion took two weeks. The CFO asked why we didn't do that sooner.’
— Solution architect, after killing his own pet view
Rebuild when the patch count exceeds three per table per quarter. Rebuild when the same data type mismatch appears in two unrelated reports. Rebuild when the junior analyst says 'I am scared to modify the model because I don't know what it breaks.' That fear is your signal. Patch only for isolated, time-boxed fixes — a single dimension attribute that's wrong, a join key that needs a coalesce. Any fix that cascades into three other models is a trap. Cut the line.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!