
Here's the situation: your finance team has built a gorgeous Power BI dashboard that shows actual vs. budget performance across every business unit. The VP of Finance loves it. But every quarter, when it's time to update the budget assumptions, someone downloads a spreadsheet, emails it to twelve people, waits for responses, manually consolidates the inputs, and re-uploads a CSV. The beautiful dashboard goes dark for three days while this chaos plays out. Sound familiar?
Power BI's native What-If parameters are genuinely useful for local scenario exploration, but they live entirely within the browser session — nobody's inputs are saved, nobody can see what their colleagues entered, and the moment you close the report, it's gone. Real enterprise planning requires a mechanism where a user in the report can enter a value, press a button, and have that value written back to a persistent data store — one that other users can immediately read, that creates an audit trail, and that flows through your existing approval processes. That's writeback, and it changes Power BI from a read-only window into a two-way planning platform.
By the end of this lesson, you'll have built a fully functional writeback solution that connects a Power BI report to a SQL Azure database through Power Automate, enabling your planning team to update budget assumptions directly from the report interface. You'll understand the architecture well enough to adapt it to SharePoint Lists, Dataverse, or Azure SQL depending on your organization's constraints.
What you'll learn:
You should be comfortable with:
You'll need access to: Power BI Premium or Premium Per User (PPU) for the paginated report features we use optionally, Power Automate (any paid plan), and either Azure SQL Database or a Dataverse environment.
Before writing a single line of DAX, you need to understand why writeback is architecturally tricky in Power BI and how the solution actually works.
Power BI reports are fundamentally read-only consumers of a dataset. The dataset queries a data source, loads data into an in-memory columnar store, and the report renders it. There's no native "write to database" button. So when we talk about writeback, we're building a workaround that exploits Power BI's ability to trigger external actions — specifically, URL actions on buttons — combined with Power Automate's HTTP trigger capability.
The flow works like this:
The key insight is that the selected values in Power BI are captured as DAX measures, not as form fields. You'll use single-select slicers constrained to one selection, and a numeric What-If parameter for the input value. Those selections get embedded into the button's URL via a concatenated measure.
Here's what makes this production-ready versus a hack: you add server-side validation in Power Automate, you write to a proper relational table with timestamps and user identity, and you handle the refresh intelligently so the round-trip feels responsive rather than broken.
Let's use a concrete scenario: a regional sales planning model. Your company has 15 sales regions, and every quarter the regional managers need to submit their revised revenue targets. Currently this happens in email. You're going to bring it into Power BI.
Your existing model has these tables (simplified):
-- Actual sales data (read-only, comes from your data warehouse)
CREATE TABLE dbo.SalesActuals (
RegionID INT,
FiscalYear INT,
FiscalQuarter INT,
Revenue DECIMAL(18,2),
LoadDate DATETIME
);
-- Dimension tables
CREATE TABLE dbo.DimRegion (
RegionID INT PRIMARY KEY,
RegionName NVARCHAR(100),
RegionManagerEmail NVARCHAR(200)
);
CREATE TABLE dbo.DimCalendar (
DateKey INT PRIMARY KEY,
FiscalYear INT,
FiscalQuarter INT,
QuarterLabel NVARCHAR(20)
);
You need a dedicated writeback table. Don't write back into your actuals table or your existing budget table. Writeback data has different semantics — it's user-entered, time-stamped, subject to revision, and needs an audit trail.
CREATE TABLE dbo.RevenueTargets_Writeback (
WritebackID INT IDENTITY(1,1) PRIMARY KEY,
RegionID INT NOT NULL,
FiscalYear INT NOT NULL,
FiscalQuarter INT NOT NULL,
RevenueTarget DECIMAL(18,2) NOT NULL,
ScenarioName NVARCHAR(100) NOT NULL DEFAULT 'Base',
SubmittedBy NVARCHAR(200) NOT NULL,
SubmittedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
IsActive BIT NOT NULL DEFAULT 1,
Notes NVARCHAR(500) NULL,
RowVersion ROWVERSION -- for optimistic concurrency
);
-- Index for the most common query pattern
CREATE NONCLUSTERED INDEX IX_RevenueTargets_Active
ON dbo.RevenueTargets_Writeback (RegionID, FiscalYear, FiscalQuarter, ScenarioName)
WHERE IsActive = 1;
The IsActive flag is critical. Rather than updating rows in place (which destroys your audit trail), you'll insert a new row and set the previous row's IsActive to 0. This gives you a complete history of every target submission — who entered what, and when — without any extra logging infrastructure.
The ScenarioName column enables scenario management. The same region/year/quarter combination can have a "Base," "Optimistic," and "Conservative" target simultaneously, and your DAX can filter to the active scenario.
In Power Query, you'll load this table as a view that only returns the most recent active entry per region/year/quarter/scenario:
-- Create this as a view in your database
CREATE VIEW dbo.vw_ActiveRevenueTargets AS
SELECT
RegionID,
FiscalYear,
FiscalQuarter,
ScenarioName,
RevenueTarget,
SubmittedBy,
SubmittedAt
FROM dbo.RevenueTargets_Writeback
WHERE IsActive = 1;
In Power Query M, reference this view like any other table. The key is that this table participates in your model relationships normally — it joins to DimRegion on RegionID and to DimCalendar on FiscalYear + FiscalQuarter (or via a surrogate key if your calendar is structured that way).
This is where most tutorials fall apart — they show a simple one-slicer example. Real planning interfaces need to capture multiple dimensions simultaneously and give the user clear feedback about what they're about to submit.
You'll use Power BI's What-If parameter for the numeric input (the target revenue value), and slicers for the dimensional selections (region, year, quarter).
Create a What-If parameter called RevenueTargetInput with these settings: minimum 0, maximum 50,000,000, increment 100,000, default 0. This creates a disconnected table and a measure automatically.
Now create measures that capture the current slicer context. These measures will be used to build the URL you send to Power Automate:
-- Capture the selected Region
Selected RegionID =
VAR SelectedRegion = SELECTEDVALUE(DimRegion[RegionID], -1)
RETURN SelectedRegion
-- Capture selected Fiscal Year
Selected FiscalYear =
VAR SelectedYear = SELECTEDVALUE(DimCalendar[FiscalYear], -1)
RETURN SelectedYear
-- Capture selected Fiscal Quarter
Selected FiscalQuarter =
VAR SelectedQuarter = SELECTEDVALUE(DimCalendar[FiscalQuarter], -1)
RETURN SelectedQuarter
-- Capture the What-If slider value
Selected TargetAmount = [RevenueTargetInput Value]
The -1 sentinel value is your validation signal. If a user hasn't made a single selection (or has selected multiple), the measure returns -1, and you can use this to disable the button or show a warning.
Now build a validation measure that checks all inputs are ready:
Input Validation Status =
VAR RegionOK = [Selected RegionID] <> -1
VAR YearOK = [Selected FiscalYear] <> -1
VAR QuarterOK = [Selected FiscalQuarter] <> -1
VAR AmountOK = [Selected TargetAmount] > 0
VAR AllValid = RegionOK && YearOK && QuarterOK && AmountOK
RETURN
IF(
AllValid,
"✓ Ready to Submit",
"⚠ " &
IF(NOT RegionOK, "Select one region. ", "") &
IF(NOT YearOK, "Select one year. ", "") &
IF(NOT QuarterOK, "Select one quarter. ", "") &
IF(NOT AmountOK, "Enter a target amount > 0.", "")
)
Put this measure in a card visual. It becomes your real-time validation feedback — users can see exactly what's missing before they click Submit.
This is the heart of the client-side implementation. You'll build a measure that constructs the complete URL for your Power Automate HTTP trigger, embedding all the input values as query parameters:
Writeback URL =
VAR BaseURL = "https://prod-XX.eastus.logic.azure.com:443/workflows/YOUR_FLOW_ID/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=YOUR_SIGNATURE"
VAR RegionParam = "®ionId=" & TEXT([Selected RegionID], "0")
VAR YearParam = "&fiscalYear=" & TEXT([Selected FiscalYear], "0")
VAR QuarterParam = "&fiscalQuarter=" & TEXT([Selected FiscalQuarter], "0")
VAR AmountParam = "&targetAmount=" & TEXT([Selected TargetAmount], "0")
VAR ScenarioParam = "&scenario=Base"
RETURN BaseURL & RegionParam & YearParam & QuarterParam & AmountParam & ScenarioParam
Security Warning: The HTTP trigger URL contains a shared access signature (the
sigparameter) that grants anyone with the URL the ability to trigger your flow. Store this URL securely. In production, consider using Azure API Management in front of your flow to add proper authentication, or use the Power Automate HTTP with Azure AD auth pattern if your organization has that configured.
Add a button to your report canvas. Set its text to "Submit Target." In the button's Action settings, set the type to "Web URL" and set the Web URL field to your [Writeback URL] measure.
Now here's the production nuance most people miss: you want to disable the button when validation fails. Unfortunately, Power BI doesn't have a native button disabled state, but you can approximate it by using conditional formatting on the button's fill color — make it grey when the validation status starts with "⚠" and your brand color when it shows the checkmark. You can also use a bookmark-based approach where an overlay shape covers the button when inputs aren't valid.
Create two bookmarks: "ButtonActive" and "ButtonDisabled." In ButtonDisabled, show a transparent rectangle over the button (which eats the click) and show a warning message. In ButtonActive, hide the rectangle. Then use a measure to drive which bookmark is applied — though note this requires the user to click a separate "Check Inputs" button, which is less elegant. For most enterprise deployments, the validation card visual is sufficient.
Before a planner submits a new target, they should see what's currently in the database for their selection. Create a measure that pulls the existing target value:
Current Saved Target =
CALCULATE(
SUM(vw_ActiveRevenueTargets[RevenueTarget]),
FILTER(
vw_ActiveRevenueTargets,
vw_ActiveRevenueTargets[RegionID] = [Selected RegionID] &&
vw_ActiveRevenueTargets[FiscalYear] = [Selected FiscalYear] &&
vw_ActiveRevenueTargets[FiscalQuarter] = [Selected FiscalQuarter] &&
vw_ActiveRevenueTargets[ScenarioName] = "Base"
)
)
Variance to Existing Target =
VAR NewTarget = [Selected TargetAmount]
VAR CurrentTarget = [Current Saved Target]
RETURN
IF(
ISBLANK(CurrentTarget),
"No existing target — this will create a new entry",
FORMAT(NewTarget - CurrentTarget, "$#,##0") &
" (" & FORMAT(DIVIDE(NewTarget - CurrentTarget, CurrentTarget), "+0.0%;-0.0%") & ")"
)
Display both of these in card visuals next to your input controls. Now planners can see: "The current target is $12,400,000. You're about to submit $13,200,000, a change of +$800,000 (+6.5%)." That's a planning interface, not a hack.
Your Power Automate flow is where the real work happens: validation, the actual database write, the dataset refresh, and the response back to the user.
Create a new Instant Cloud Flow with an HTTP Request trigger (the "When an HTTP request is received" trigger). The flow will have these major steps:
When you create the HTTP trigger, Power Automate generates the URL automatically. The flow will receive query parameters (not a JSON body, since Power BI button URL actions are GET requests). You'll parse them using expressions.
After the trigger, add a "Compose" action called "Parse Inputs" that extracts and validates each parameter:
RegionID: @{triggerOutputs()['queries']['regionId']}
FiscalYear: @{triggerOutputs()['queries']['fiscalYear']}
FiscalQuarter: @{triggerOutputs()['queries']['fiscalQuarter']}
TargetAmount: @{triggerOutputs()['queries']['targetAmount']}
Scenario: @{triggerOutputs()['queries']['scenario']}
Add a Condition action to validate that none of these are empty or contain your sentinel value -1:
Condition:
AND(
not(empty(triggerOutputs()['queries']['regionId'])),
not(equals(triggerOutputs()['queries']['regionId'], '-1')),
not(empty(triggerOutputs()['queries']['targetAmount'])),
greater(float(triggerOutputs()['queries']['targetAmount']), 0)
)
If the condition is false, add a Response action in the "No" branch that returns HTTP 400 with a body of {"status": "error", "message": "Invalid input parameters. Ensure all fields are selected and target amount is greater than zero."}.
In the "Yes" branch, add a SQL Server "Execute stored procedure" action or an "Execute a SQL query" action. Using a stored procedure is strongly preferred in production because it keeps your business logic in the database where it's versioned and testable.
Create this stored procedure in your database:
CREATE PROCEDURE dbo.usp_UpsertRevenueTarget
@RegionID INT,
@FiscalYear INT,
@FiscalQuarter INT,
@RevenueTarget DECIMAL(18,2),
@ScenarioName NVARCHAR(100),
@SubmittedBy NVARCHAR(200),
@Notes NVARCHAR(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRANSACTION;
BEGIN TRY
-- Soft-delete existing active rows for this combination
UPDATE dbo.RevenueTargets_Writeback
SET IsActive = 0
WHERE RegionID = @RegionID
AND FiscalYear = @FiscalYear
AND FiscalQuarter = @FiscalQuarter
AND ScenarioName = @ScenarioName
AND IsActive = 1;
-- Insert the new target
INSERT INTO dbo.RevenueTargets_Writeback
(RegionID, FiscalYear, FiscalQuarter, RevenueTarget,
ScenarioName, SubmittedBy, SubmittedAt, IsActive, Notes)
VALUES
(@RegionID, @FiscalYear, @FiscalQuarter, @RevenueTarget,
@ScenarioName, @SubmittedBy, SYSUTCDATETIME(), 1, @Notes);
COMMIT TRANSACTION;
-- Return the WritebackID for confirmation
SELECT SCOPE_IDENTITY() AS WritebackID, SYSUTCDATETIME() AS ConfirmedAt;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
In Power Automate, call this stored procedure with the parameters mapped from your parsed inputs. For the @SubmittedBy parameter, use the Power Automate expression @{workflow()['tags']['flowDisplayName']} — or better, if you've configured Azure AD auth on your flow, use @{triggerOutputs()['headers']['X-MS-CLIENT-PRINCIPAL-NAME']} to capture the actual user's email. This is your audit trail.
Important: The user identity capture depends on how your HTTP trigger is secured. For proper user attribution, consider routing the call through an Azure API Management policy that adds the authenticated user's claim to the request header before forwarding to Power Automate.
After the successful SQL write, you want Power BI to refresh so the new data is visible. Add a Power BI "Refresh a dataset" action, configured with your workspace and dataset.
There's a subtle problem here: the refresh is asynchronous. Power Automate fires the refresh request and moves on, but the actual refresh might take 30 seconds to 3 minutes depending on your data volume. The button click in Power BI will have returned by then.
The realistic approach for most datasets under a few hundred million rows is:
For datasets that need near-real-time confirmation, look into DirectQuery mode for your writeback table specifically — you can use a composite model where your actuals are Import mode and your vw_ActiveRevenueTargets is DirectQuery. This way the writeback table is always queried live, and the user sees their submission immediately after the flow completes.
Add a final Response action in your success path:
{
"status": "success",
"message": "Revenue target submitted successfully",
"writebackId": @{body('Execute_stored_procedure')?['ResultSets']?['Table1']?[0]?['WritebackID']},
"confirmedAt": "@{body('Execute_stored_procedure')?['ResultSets']?['Table1']?[0]?['ConfirmedAt']}",
"submittedBy": "@{triggerOutputs()['queries']['submittedBy']}"
}
Set the HTTP status code to 200 and the Content-Type header to application/json.
Unfortunately, Power BI button URL actions don't display the HTTP response to the user — the browser just opens the URL. This is a significant UX limitation. There are two common workarounds:
Option A: The flow sends an email confirmation (use the Office 365 Outlook "Send an email" action) to the submitter. Clean and auditable.
Option B: Write the confirmation back to a separate "Confirmation" table in your database, which your Power BI dataset polls via a parameter-driven query. This is complex but gives in-report confirmation.
For most enterprise planning scenarios, email confirmation is the right answer. Planners are used to submission confirmations in their inbox.
The ScenarioName column you built into the writeback table unlocks genuine scenario management. Let's build it out.
Add a second What-If parameter (text-based isn't natively supported, so use a disconnected table instead):
-- In Power Query, create a new table called ScenarioOptions
-- Columns: ScenarioID (integer), ScenarioName (text), ScenarioDescription (text)
-- Rows: 1/Base/Conservative baseline, 2/Optimistic/Upside scenario, 3/Stress/Downside stress test
Add a slicer on ScenarioOptions[ScenarioName]. Capture the selection in a measure:
Selected Scenario = SELECTEDVALUE(ScenarioOptions[ScenarioName], "Base")
Now modify your Writeback URL measure to include &scenario= & [Selected Scenario].
The real value of scenario management is the comparison view. Create measures for each scenario's targets:
Base Target =
CALCULATE(
SUM(vw_ActiveRevenueTargets[RevenueTarget]),
vw_ActiveRevenueTargets[ScenarioName] = "Base"
)
Optimistic Target =
CALCULATE(
SUM(vw_ActiveRevenueTargets[RevenueTarget]),
vw_ActiveRevenueTargets[ScenarioName] = "Optimistic"
)
Scenario Upside =
VAR BaseAmt = [Base Target]
VAR OptAmt = [Optimistic Target]
RETURN
DIVIDE(OptAmt - BaseAmt, BaseAmt, BLANK())
Build a comparison table or clustered bar chart using these measures, with Region on the rows. Now your planning team can see: "If the Optimistic scenario plays out, what's the revenue upside by region?"
Once finance approves the Base scenario, you don't want anyone overwriting it. Add an approval status table:
CREATE TABLE dbo.ScenarioApprovals (
ScenarioName NVARCHAR(100) NOT NULL,
FiscalYear INT NOT NULL,
FiscalQuarter INT NOT NULL,
IsLocked BIT NOT NULL DEFAULT 0,
ApprovedBy NVARCHAR(200),
ApprovedAt DATETIME2,
PRIMARY KEY (ScenarioName, FiscalYear, FiscalQuarter)
);
In your Power Automate flow, before writing to the database, add a SQL query step:
SELECT IsLocked FROM dbo.ScenarioApprovals
WHERE ScenarioName = '@{triggerOutputs()['queries']['scenario']}'
AND FiscalYear = @{triggerOutputs()['queries']['fiscalYear']}
AND FiscalQuarter = @{triggerOutputs()['queries']['fiscalQuarter']}
Add a condition: if IsLocked = 1, return HTTP 403 with {"status": "locked", "message": "This scenario has been approved and is locked for editing. Contact your finance administrator to unlock it."}.
Load the ScenarioApprovals table into Power BI as well. Create a DAX measure:
Scenario Lock Status =
VAR IsLocked =
CALCULATE(
MAX(ScenarioApprovals[IsLocked]),
ScenarioApprovals[ScenarioName] = [Selected Scenario],
ScenarioApprovals[FiscalYear] = [Selected FiscalYear],
ScenarioApprovals[FiscalQuarter] = [Selected FiscalQuarter]
)
RETURN IF(IsLocked = 1, "🔒 APPROVED - Read Only", "✏️ Open for Editing")
Display this prominently above your input controls. Planners immediately know whether they can edit the scenario they're viewing.
Let's put it all together. You're building a Q3 planning dashboard for the North America sales org. Here's what you'll create from scratch:
Setup (15 minutes):
RevenueTargets_Writeback and ScenarioApprovals tables in a test Azure SQL Database (or use a local SQL Server instance if Azure isn't available — the flow connector supports both).DimRegion with 5 fake regions: Northeast, Southeast, Midwest, Southwest, West.SalesActuals for FY2024 Q1-Q2.Power Automate Flow (20 minutes):
Power BI Report (30 minutes):
vw_ActiveRevenueTargets view.End-to-End Test (10 minutes):
Checkpoint questions to verify your understanding:
This almost always means your Writeback URL measure is returning a URL that Power BI won't open because it doesn't start with http:// or https://. Check your measure for any leading spaces or concatenation errors. Also, Power BI Service will block certain URL patterns — make sure your Power Automate HTTP trigger URL uses HTTPS (it always does, but double-check your measure logic isn't accidentally stripping it).
Your slicer is allowing multi-select or the user hasn't made a selection. Make sure your DimRegion slicer has "Single select" enabled in its slicer settings. Also verify that the measure Selected RegionID is using SELECTEDVALUE not VALUES or MIN. SELECTEDVALUE returns the sentinel value when multiple items are selected; MIN silently picks the minimum and gives you a false "valid" state.
You're looking at cached data. The dataset hasn't refreshed yet. If you're on Import mode for the writeback view, you need to wait for the next scheduled refresh or trigger a manual one. If this is happening even after refresh, check that your view vw_ActiveRevenueTargets is filtering on IsActive = 1 correctly and that the Power Query query for this table isn't applying its own filter that's caching the old results.
Your stored procedure handles this correctly at the database level (the UPDATE/INSERT pattern within a transaction), but the UX problem is that planners aren't seeing each other's submissions. This is because they're all looking at the same cached dataset. The solution is either DirectQuery for the writeback view (immediate visibility) or a Power BI dataset push refresh triggered by your Power Automate flow for all users viewing the report. The latter is complex; for most teams, a "last write wins" policy with the email confirmation audit trail is sufficient.
Your stored procedure is taking too long. This usually happens when the RevenueTargets_Writeback table has grown very large and the UPDATE step is scanning too many rows. Make sure your index includes IsActive = 1 as a filter (which the filtered index in our schema does). If you're still timing out, partition the writeback table by FiscalYear.
If you're using the HTTP trigger with security enabled (which you should be), the URL includes a signature parameter. If this signature expires or is regenerated, all existing Submit buttons break simultaneously. Document the process for updating the Writeback URL measure in your Power BI model and republishing when this happens. Consider wrapping the Power Automate trigger behind Azure API Management with a stable URL and rotating only the backend credentials there.
This architecture works beautifully for planning workflows where:
It starts to strain under:
For high-frequency or real-time scenarios, consider moving to a Dataverse backend (which has native Power BI DirectQuery support and much tighter integration with Power Apps and Power Automate) or Azure Synapse Link for Dataverse if you need analytics on top of operational data.
The Dataverse approach also solves the authentication problem elegantly — Dataverse uses the user's Microsoft 365 identity natively, so you get proper per-user audit trails without any API Management overhead.
You've built a complete enterprise writeback solution: a Power BI report that captures structured planning inputs, validates them client-side, transmits them to Power Automate via a URL action, writes them to a SQL database with full audit history, supports multiple named scenarios with approval locking, and confirms the submission to the user via email.
The core architectural insight is that Power BI's read-only nature is worked around by using the button's URL action as a message carrier, not by fighting the platform. Power Automate handles all the stateful operations — writing, validating, refreshing — that Power BI was never designed to do.
Where to go from here:
Add row-level security to the planning interface. Regional managers should only be able to submit targets for their own region. Implement RLS in your Power BI dataset using USERPRINCIPALNAME() filtered against the RegionManagerEmail in DimRegion. Combine this with server-side validation in Power Automate that cross-checks the submitted RegionID against the email extracted from the request headers.
Build a Power Apps embedded input panel. For complex input scenarios with many fields, embedding a Power Apps canvas app directly in Power BI gives you proper form controls, client-side validation, and native Dataverse connectivity. The Power BI button writeback approach works great for simple 2-3 field inputs; Power Apps handles the rest.
Implement an approval workflow. Extend the Power Automate flow to route submitted targets to the finance manager for approval before they become IsActive = 1. This turns your writeback solution into a proper workflow system with approvals, rejections, and resubmissions — all triggered from Power BI.
Explore the Power BI REST API for dataset refresh. Rather than relying on scheduled refresh, use the Power Automate Power BI connector's "Refresh a dataset" action and then poll the "Get refresh history" action to wait for completion before sending the confirmation email. This gives planners an accurate "your data is now visible" notification rather than a generic "refresh triggered" message.
Learning Path: Enterprise Power BI