Direct Lake mode is Power BI's fastest connection strategy for Fabric lakehouses — but only if your Delta tables are structured correctly. This lesson teaches you how to create V-Ordered gold tables, manage framing, diagnose DirectQuery fallback, and keep your reports fresh without expensive import cycles.

Picture this: your analytics team has spent months building a beautiful gold-layer lakehouse in Microsoft Fabric. Sales figures, customer dimensions, inventory metrics — all landing neatly as Delta tables after a multi-stage transformation pipeline. Now your business stakeholders want Power BI reports on that data. You create a semantic model, point it at the lakehouse, and then comes the question that trips up nearly every team at this stage: should I use Import, DirectQuery, or Direct Lake mode?
Import gives you speed but requires scheduled refreshes and has capacity limits. DirectQuery gives you live data but hammers your source system with query-time SQL. Direct Lake is the third option that Microsoft built specifically for this Fabric-native world — it reads Parquet files from OneLake directly into memory, without copying data into Power BI's VertiPaq engine and without going through a SQL translation layer at query time. The result is import-style performance with near-real-time freshness. But getting it right requires you to understand how Delta tables work under the hood, how framing works, and how to structure and maintain your tables so Direct Lake doesn't silently fall back to DirectQuery when you least expect it.
By the end of this lesson, you'll have the knowledge and hands-on practice to confidently wire up a Power BI semantic model to a Fabric lakehouse, keep it fresh without full refreshes, and tune your Delta tables for the fast analytical queries your stakeholders expect.
What you'll learn:
This lesson assumes you're comfortable with the Microsoft Fabric environment and have worked with lakehouses before. Specifically, you should know:
Before you write a single line of DAX or drag a table into a semantic model, you need to understand what's happening mechanically. This is the part most tutorials skip, and it's the reason teams end up confused about why their reports are slow or why framing suddenly shows up as an error.
A Delta table in your lakehouse is stored as a collection of Parquet files in OneLake, with a _delta_log directory tracking which files belong to the current version of the table. When Power BI connects in Direct Lake mode, it doesn't run SQL against your SQL Analytics Endpoint — it reads those Parquet files directly, column by column, using its own internal engine. This is what makes it fast: there's no query translation, no JDBC round-trips, no SQL optimizer in the path.
The catch is that Direct Lake has to know which Parquet files are valid for the current version of the table. It discovers this through a process called framing. When a Direct Lake semantic model is loaded or refreshed, it inspects the Delta transaction log, identifies the current set of active Parquet files, and "frames" those files as the dataset. From that point forward, the engine reads those specific files directly — even as new writes add new files to the table. Your report is reading a consistent snapshot.
Key insight
Framing is not a data copy. Power BI isn't moving bytes into its own storage. It's simply recording which Parquet files to read, like bookmarking pages in a book. The actual data stays in OneLake.
This has an important implication: if your lakehouse table gets new data written to it after framing, users won't see that data until the next frame is applied. A frame can be triggered manually, on a schedule, or automatically (more on this shortly). Understanding the framing cycle is the key to managing freshness expectations.
There's a second mechanism you need to know about: fallback to DirectQuery. When Direct Lake can't satisfy a query from Parquet files — because of unsupported data types, because a table has too many columns beyond the SKU limits, or because something about the Delta table isn't clean — it quietly falls back to issuing SQL queries through the SQL Analytics Endpoint instead. This is safe in that it won't break your report, but it's slow and it means your "Direct Lake" connection isn't actually working as intended. We'll come back to diagnosing and preventing this.
The tables you expose to Direct Lake should be your gold layer — denormalized or lightly normalized, clean, typed correctly, and structured for analytical access. Raw or silver-layer tables tend to have messy schemas, too many small files, and data types that cause headaches.
Let's build a realistic example. Imagine a retail analytics scenario with these gold tables:
gold_fact_sales — transactional sales datagold_dim_product — product attributesgold_dim_store — store location datagold_dim_date — a date dimensionHere's how you'd write a well-structured gold fact table using PySpark in a Fabric notebook:
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, to_date, year, month, dayofmonth
from delta.tables import DeltaTable
spark = SparkSession.builder.getOrCreate()
# Read from silver layer
silver_sales = spark.read.format("delta").load(
"abfss://your-workspace@onelake.dfs.fabric.microsoft.com/your-lakehouse.Lakehouse/Tables/silver_sales"
)
# Build the gold fact table with clean types and surrogate keys
gold_sales = (
silver_sales
.select(
col("sale_id").cast("long").alias("SaleID"),
col("sale_date").cast("date").alias("SaleDate"),
col("product_key").cast("integer").alias("ProductKey"),
col("store_key").cast("integer").alias("StoreKey"),
col("quantity").cast("integer").alias("Quantity"),
col("unit_price").cast("double").alias("UnitPrice"),
col("discount_amount").cast("double").alias("DiscountAmount"),
col("net_revenue").cast("double").alias("NetRevenue")
)
.filter(col("SaleDate").isNotNull())
)
# Write to the gold layer with V-Order enabled (critical for Direct Lake performance)
(
gold_sales
.write
.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.option("overwriteSchema", "true")
.mode("overwrite")
.saveAsTable("gold_fact_sales")
)
print("Gold fact table written successfully.")
Notice the .option("delta.parquet.vorder.enabled", "true") line. V-Order is a Microsoft-specific optimization that applies additional sorting and encoding to Parquet files at write time. It makes Power BI's column-store reads significantly faster because the data is physically organized to match how VertiPaq-style engines access it. V-Order is enabled by default on Fabric Spark, but it's worth making it explicit so you don't accidentally lose it if configurations change.
Tip
V-Order adds a small amount of overhead at write time, but the read-time gains for Power BI are substantial — Microsoft reports up to 10x faster reads on V-Ordered files compared to standard Parquet. Always enable it for your gold reporting tables.
For incremental loads on your fact table, use a merge pattern rather than full overwrites. Full overwrites create a clean table but generate a lot of file churn:
from delta.tables import DeltaTable
# Load new data from a staging area or silver table
new_sales = spark.read.format("delta").load(
"abfss://your-workspace@onelake.dfs.fabric.microsoft.com/your-lakehouse.Lakehouse/Tables/silver_sales_staged"
)
# Get reference to the existing gold fact table
gold_table = DeltaTable.forName(spark, "gold_fact_sales")
# Merge: update existing rows, insert new ones
(
gold_table.alias("existing")
.merge(
new_sales.alias("incoming"),
"existing.SaleID = incoming.SaleID"
)
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
print(f"Merge complete.")
After a merge, your Delta table may accumulate many small files — each merge operation can create new Parquet files. Small files are the enemy of Direct Lake performance. Run an OPTIMIZE command periodically to compact them:
spark.sql("OPTIMIZE gold_fact_sales")
For very large fact tables, add Z-ordering on your most-common filter columns. Z-ordering physically co-locates rows that share similar values for the specified columns, so when Power BI filters on SaleDate or StoreKey, it only needs to read a fraction of the files:
spark.sql("OPTIMIZE gold_fact_sales ZORDER BY (SaleDate, StoreKey)")
Warning
Z-ordering is expensive to compute and rewrites a significant portion of your table's files. Don't run it after every incremental load. Schedule it as a weekly or nightly maintenance job, or trigger it when cumulative file count crosses a threshold.
Finally, run VACUUM to remove old Parquet files that are no longer referenced by the Delta log. By default, Delta retains 7 days of history for time travel. If you don't need time travel, you can reduce this to 1-2 days and vacuum more aggressively:
spark.sql("ALTER TABLE gold_fact_sales SET TBLPROPERTIES ('delta.logRetentionDuration' = 'interval 2 days')")
spark.sql("VACUUM gold_fact_sales RETAIN 48 HOURS")
Now that your gold Delta tables are well-structured and optimized, it's time to connect Power BI. There are two ways to create a Direct Lake semantic model in Fabric:
Option 1: Auto-generated from the Lakehouse
When you open your Fabric lakehouse and navigate to the SQL Analytics Endpoint view, you'll see a button in the top ribbon labeled "New semantic model." Clicking it opens a panel where you can select which tables to include. When you select tables from a Fabric lakehouse and create the model this way, Fabric automatically creates a Direct Lake semantic model — not an Import or DirectQuery model. The connection is wired up automatically; you don't enter a connection string.
Select gold_fact_sales, gold_dim_product, gold_dim_store, and gold_dim_date from your list, give the model a name like RetailAnalytics_SemanticModel, and click Confirm. Fabric creates the model as a separate item in your workspace.
Option 2: Create via Power BI Desktop with the Fabric Lakehouse connector
Open Power BI Desktop and choose "Get Data" → "Microsoft Fabric" → "Lakehouses." Sign in, navigate to your workspace, and select the lakehouse. Desktop will ask which mode to use. Choose Direct Lake if the option is available (it requires your workspace to be on Fabric capacity). Select your four gold tables.
Note
Power BI Desktop's Direct Lake experience is still maturing. For production models, the web-based authoring experience in the Fabric portal (using the semantic model editor) gives you more control over relationships, measures, and model properties. Desktop is great for measure development and testing.
Once your model is created, open it in the Fabric semantic model editor. You'll land on a canvas that looks similar to Power BI Desktop's model view. Your first tasks are to define relationships and add your core measures.
Set up your star schema relationships. Click between tables to define relationships:
gold_fact_sales[ProductKey] → gold_dim_product[ProductKey] (Many-to-One, single direction)gold_fact_sales[StoreKey] → gold_dim_store[StoreKey] (Many-to-One, single direction)gold_fact_sales[SaleDate] → gold_dim_date[DateKey] (Many-to-One, single direction)Stick with single-direction cross-filtering for large fact tables. Bidirectional relationships can cause unexpected cardinality expansion and slower query plans in Direct Lake, just as they do in Import mode.
Add your core business measures in the model editor. These work exactly as they do in Import mode — Direct Lake doesn't change how DAX is authored:
Total Revenue = SUM(gold_fact_sales[NetRevenue])
Total Quantity = SUM(gold_fact_sales[Quantity])
Average Unit Price = AVERAGEX(gold_fact_sales, gold_fact_sales[UnitPrice])
Revenue YTD =
TOTALYTD(
[Total Revenue],
gold_dim_date[DateKey]
)
Revenue vs Prior Year =
VAR CurrentRevenue = [Total Revenue]
VAR PriorRevenue = CALCULATE([Total Revenue], SAMEPERIODLASTYEAR(gold_dim_date[DateKey]))
RETURN
DIVIDE(CurrentRevenue - PriorRevenue, PriorRevenue, BLANK())
Here's where many teams get confused about freshness. Your semantic model is framed — it's looking at a fixed snapshot of your Delta tables. When does that snapshot update?
Framing can be triggered in three ways:
Automatic framing — Fabric monitors your Delta tables and automatically applies a new frame when it detects changes, within a few minutes. This is the default behavior and works well for most use cases.
Scheduled semantic model refresh — You can set a refresh schedule on the semantic model (just like Import mode). However, for Direct Lake, a "refresh" doesn't copy data — it re-frames the model against the latest Delta table state. It's fast.
Programmatic framing via the XMLA endpoint or REST API — For precise control, you can trigger a frame from a pipeline or notebook.
To trigger framing programmatically at the end of a data pipeline — ensuring reports reflect the latest load immediately — use the Fabric REST API. Here's a Python script you might run in a notebook or pipeline activity:
import requests
import msal
# Authentication
tenant_id = "your-tenant-id"
client_id = "your-service-principal-client-id"
client_secret = "your-client-secret"
dataset_id = "your-semantic-model-id" # Find in the URL when viewing the model
authority = f"https://login.microsoftonline.com/{tenant_id}"
app = msal.ConfidentialClientApplication(client_id, client_secret, authority=authority)
token = app.acquire_token_for_client(["https://analysis.windows.net/powerbi/api/.default"])
access_token = token["access_token"]
# Trigger semantic model refresh (which re-frames the Direct Lake model)
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
refresh_url = f"https://api.powerbi.com/v1.0/myorg/datasets/{dataset_id}/refreshes"
response = requests.post(refresh_url, headers=headers, json={"type": "full"})
if response.status_code == 202:
print("Semantic model refresh triggered successfully.")
else:
print(f"Error: {response.status_code} - {response.text}")
For most teams, automatic framing is sufficient. If your pipeline runs every hour and stakeholders can tolerate a few minutes of lag, you don't need the programmatic trigger. If you have strict SLAs — "reports must reflect data within 5 minutes of the pipeline completing" — the programmatic trigger at the end of your pipeline gives you that control. You can read more about orchestrating this end-to-end in Orchestrating Loads with Fabric Data Pipelines.
Tip
You can check the current framing status of a Direct Lake semantic model in the Fabric portal by navigating to the workspace, selecting the semantic model, and looking at the "Refresh history" tab. Each entry labeled "Framing" shows when the snapshot was last applied.
Fallback is the most common silent failure in Direct Lake deployments. Your model appears to work, but it's secretly running SQL queries against the SQL Analytics Endpoint instead of reading Parquet directly. Reports load slowly, capacity usage spikes, and you're confused because nothing looks broken.
Here's how to detect it. Open DAX Studio and connect to your semantic model via the XMLA endpoint (available on Fabric and Premium capacities). Run a simple query:
EVALUATE
SUMMARIZECOLUMNS(
gold_dim_product[Category],
"Total Revenue", [Total Revenue]
)
In DAX Studio's Server Timings pane, look for "Storage Engine" query types. If you see DirectQuery queries being fired rather than VertiPaq SE (Direct Lake) queries, you have fallback. The DQ prefix in the storage engine timings is the telltale sign.
1. Unsupported data types
Direct Lake doesn't support every data type that SQL supports. Common offenders:
DECIMAL columns with precision > 38 or scale > 18 → Cast to double or float in your PySpark writeDATETIMEOFFSET columns → Convert to UTC timestamp before writingBINARY columns → Remove these from your gold layer or cast to string if they contain encodable data2. Too many columns relative to your SKU
Each Fabric SKU has a limit on how many columns a Direct Lake table can have in-memory simultaneously. F2 allows fewer than F64. If a single table has 300+ columns, Direct Lake may fall back. The solution is to split wide tables into multiple narrower tables or remove unused columns from the gold layer aggressively.
3. Delta table issues — missing or corrupt files
If Parquet files referenced in the Delta log are missing (perhaps deleted by an aggressive VACUUM or an accidental file operation), Direct Lake can't complete framing and falls back. Run DESCRIBE HISTORY gold_fact_sales in the lakehouse SQL editor and look for any errors in recent operations. Prevent this by using Delta operations (MERGE, DELETE, UPDATE) rather than manipulating files directly.
4. Calculated columns and certain DAX patterns
Some DAX calculated columns force query-time computation that Direct Lake handles through DirectQuery fallback. Prefer pre-computing columns in your PySpark transformation layer rather than defining calculated columns in the semantic model. If a business rule is complex, materialize it in the gold table rather than expressing it as a DAX calculated column.
Warning
Fallback is often invisible to report users — queries still return results, just slowly. You need to proactively monitor for it using DAX Studio or the Fabric Monitoring Hub. Don't assume your Direct Lake model is actually running in Direct Lake mode just because it was created that way.
For fact tables with billions of rows, partitioning your Delta table can dramatically reduce how many Parquet files Direct Lake needs to read for time-bounded queries. Delta table partitioning in Spark is straightforward:
(
gold_sales
.write
.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.partitionBy("SaleYear", "SaleMonth")
.mode("overwrite")
.saveAsTable("gold_fact_sales_partitioned")
)
Wait — should you partition by date? This is one of the most debated questions in Direct Lake optimization. Here's the nuance:
Partition by a date column if:
Don't partition if:
SaleDate values — partition by year or year-month insteadKey insight
Direct Lake's performance advantage comes from reading Parquet files in columnar fashion — it's already efficient at skipping columns it doesn't need. Row-level partitioning helps when you can eliminate entire files, but it only pays off at very large scales. For most retail or financial fact tables under 500 million rows, V-Order and OPTIMIZE are more impactful than partitioning.
Let's put everything together. You'll build a complete pipeline from raw data to a working Power BI report in Direct Lake mode.
Scenario: You work for a regional grocery chain. Sales data lands daily in your lakehouse as CSV files. You need a Power BI report showing daily revenue, top products, and store performance, refreshed automatically each morning.
Open a new Spark notebook in your Fabric workspace and attach it to your lakehouse. Run the following to create realistic gold tables:
from pyspark.sql.functions import col, to_date, year, month, lit
from pyspark.sql.types import *
import random
from datetime import date, timedelta
# Simulate a product dimension
products = [(i, f"Product_{i}", ["Produce", "Dairy", "Bakery", "Meat", "Frozen"][i % 5],
round(random.uniform(1.5, 25.0), 2))
for i in range(1, 201)]
product_schema = StructType([
StructField("ProductKey", IntegerType()),
StructField("ProductName", StringType()),
StructField("Category", StringType()),
StructField("StandardCost", DoubleType())
])
df_products = spark.createDataFrame(products, product_schema)
(df_products.write.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.mode("overwrite")
.saveAsTable("gold_dim_product"))
# Simulate a store dimension
stores = [(i, f"Store_{i:03d}", ["North", "South", "East", "West"][i % 4],
f"City_{i % 20}")
for i in range(1, 51)]
store_schema = StructType([
StructField("StoreKey", IntegerType()),
StructField("StoreName", StringType()),
StructField("Region", StringType()),
StructField("City", StringType())
])
df_stores = spark.createDataFrame(stores, store_schema)
(df_stores.write.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.mode("overwrite")
.saveAsTable("gold_dim_store"))
# Simulate 2 years of date dimension
start = date(2023, 1, 1)
dates = []
for i in range(730):
d = start + timedelta(days=i)
dates.append((d, d.year, d.month, d.day, d.strftime("%A"), d.strftime("%B"),
d.isocalendar()[1]))
date_schema = StructType([
StructField("DateKey", DateType()),
StructField("Year", IntegerType()),
StructField("Month", IntegerType()),
StructField("Day", IntegerType()),
StructField("DayOfWeek", StringType()),
StructField("MonthName", StringType()),
StructField("WeekNumber", IntegerType())
])
df_dates = spark.createDataFrame(dates, date_schema)
(df_dates.write.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.mode("overwrite")
.saveAsTable("gold_dim_date"))
# Simulate fact sales (5 million rows)
import random
random.seed(42)
sales = []
for i in range(1, 5_000_001):
sale_date = start + timedelta(days=random.randint(0, 729))
product_key = random.randint(1, 200)
store_key = random.randint(1, 50)
quantity = random.randint(1, 20)
unit_price = round(random.uniform(1.5, 25.0), 2)
discount = round(unit_price * quantity * random.uniform(0, 0.15), 2)
net_revenue = round(unit_price * quantity - discount, 2)
sales.append((i, sale_date, product_key, store_key, quantity, unit_price, discount, net_revenue))
sales_schema = StructType([
StructField("SaleID", LongType()),
StructField("SaleDate", DateType()),
StructField("ProductKey", IntegerType()),
StructField("StoreKey", IntegerType()),
StructField("Quantity", IntegerType()),
StructField("UnitPrice", DoubleType()),
StructField("DiscountAmount", DoubleType()),
StructField("NetRevenue", DoubleType())
])
df_sales = spark.createDataFrame(sales, sales_schema)
(df_sales.write.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.mode("overwrite")
.saveAsTable("gold_fact_sales"))
# Run OPTIMIZE to compact files
spark.sql("OPTIMIZE gold_fact_sales")
spark.sql("OPTIMIZE gold_dim_product")
spark.sql("OPTIMIZE gold_dim_store")
spark.sql("OPTIMIZE gold_dim_date")
print("All gold tables created and optimized.")
GroceryAnalytics and select gold_fact_sales, gold_dim_product, gold_dim_store, and gold_dim_date.Open the GroceryAnalytics semantic model. In the model editor:
Create relationships (drag between tables):
gold_fact_sales[ProductKey] → gold_dim_product[ProductKey]gold_fact_sales[StoreKey] → gold_dim_store[StoreKey]gold_fact_sales[SaleDate] → gold_dim_date[DateKey]Add measures to the gold_fact_sales table:
Total Revenue = SUM(gold_fact_sales[NetRevenue])
Total Units = SUM(gold_fact_sales[Quantity])
Avg Transaction Value = DIVIDE([Total Revenue], COUNTROWS(gold_fact_sales))
Revenue MTD = TOTALMTD([Total Revenue], gold_dim_date[DateKey])
ProductKey, StoreKey) from report view to keep the field list clean.Create a new report from the semantic model. Build three visuals:
gold_dim_date[DateKey] on the X-axis and [Total Revenue] on the values axisgold_dim_product[Category] on the axis and [Total Revenue] on valuesgold_dim_store[Region] on rows, gold_dim_date[MonthName] on columns, and [Total Revenue] on valuesSave and publish the report. Notice how quickly the visuals load — you're experiencing Direct Lake reading V-Ordered Parquet files from OneLake at near-import speed.
Back in your notebook, simulate a new day's sales arriving:
from datetime import date
new_sales_data = [(5_000_001 + i, date(2024, 12, 15),
random.randint(1, 200), random.randint(1, 50),
random.randint(1, 20), round(random.uniform(1.5, 25.0), 2),
0.0, round(random.uniform(10.0, 200.0), 2))
for i in range(10_000)]
df_new = spark.createDataFrame(new_sales_data, sales_schema)
(df_new.write.format("delta")
.option("delta.parquet.vorder.enabled", "true")
.mode("append")
.saveAsTable("gold_fact_sales"))
print("New data appended. Check report after framing occurs.")
Wait 2-3 minutes, then refresh your Power BI report. You should see December 15, 2024 data appearing — no manual dataset refresh required, no scheduled import job running. That's automatic framing doing its work.
"My report is loading slowly even though I'm using Direct Lake."
First, confirm you're actually in Direct Lake mode and not experiencing fallback. Use DAX Studio as described above. If you're confirmed in Direct Lake, check:
DESCRIBE DETAIL gold_fact_sales report? If it's thousands of small files, run OPTIMIZE."I get an error: 'The dataset exceeded the memory limit.'"
Your semantic model is trying to load more data into memory than your Fabric SKU allows. Solutions: reduce the number of tables in the model, remove unused columns from your gold tables before creating the semantic model, or upgrade your capacity SKU.
"New data isn't showing in reports."
Framing may not have triggered yet. Check the semantic model refresh history. If automatic framing seems to be delayed, trigger a manual refresh from the workspace or set up a scheduled refresh as a fallback. Also verify that data actually landed in the lakehouse table — check DESCRIBE HISTORY gold_fact_sales to confirm the write succeeded.
"The semantic model editor shows some columns as 'unsupported.'"
You have a data type that Direct Lake can't handle. Go back to your PySpark notebook, identify the column, cast it to a supported type (string, integer, long, double, date, timestamp, boolean), and rewrite the table. Then re-create or refresh the semantic model.
"My OPTIMIZE job is taking hours."
You may be running Z-ORDER on too many columns or on a massive table without a predicate. Run OPTIMIZE in batches using partition predicates:
spark.sql("OPTIMIZE gold_fact_sales WHERE SaleDate >= '2024-01-01' ZORDER BY (SaleDate, StoreKey)")
You've covered a lot of ground. Let's consolidate what you now understand:
Direct Lake mode is Power BI's native connection strategy for Fabric lakehouses — it reads V-Ordered Parquet files directly from OneLake without copying data or going through SQL. The framing mechanism creates a consistent snapshot that updates automatically or on demand. Your job as the data engineer is to produce gold-layer Delta tables that are clean, correctly typed, compacted, and V-Ordered so that Direct Lake can operate at its best.
The key practices to carry forward:
To continue building on this foundation, explore Direct Lake Mode in Power BI: How It Works and When to Use It for a deeper dive into the architecture and SKU limits. If your gold tables are being populated through a medallion architecture, Implementing the Medallion Architecture in Microsoft Fabric covers how to structure the full pipeline from bronze to gold. And when you're ready to think about data access controls — who can see which rows and columns in your semantic model — Securing and Governing Microsoft Fabric is your next stop.