Tally + SQL Server: The Data Warehouse Middle Layer
Move beyond direct ODBC queries. Build a production-grade SQL Server data warehouse from TallyPrime. Master ODBC extraction, staging schema design, Python and SSIS ETL, incremental loads, star schema, data quality checks, Tally-vs-SQL reconciliation, and Power BI consumption.
Why SQL Server Between TallyPrime and Analytics
In Part 25, you learned how to connect Power BI and Excel directly to Tally via ODBC. That works for small companies with simple reporting. But once you have years of data, dozens of users, multiple companies, and complex analytics, direct ODBC integration breaks down.
The 6 Problems with Direct Tally → Power BI
| Problem | Impact |
|---|---|
| Load on live Tally | Each refresh queries the production server, slowing down daily operations |
| No historical data | If Tally archived or split a company, older data is not accessible |
| Limited SQL | Tally ODBC does not support window functions, CTEs, or complex joins |
| Slow for large datasets | Querying 500k+ vouchers directly via Tally ODBC takes minutes |
| No data blending | Cannot join Tally data with CRM, HRMS, or external sources |
| Single-point queries | Every dashboard tile hits Tally separately, multiplying load |
How SQL Server Solves All Six
The Three-Tier Architecture
Who Should Build This Architecture
- Enterprises with 500k+ vouchers where direct ODBC is too slow
- Businesses with 20+ dashboard users where direct Tally load becomes a problem
- Multi-company groups that need consolidated analytics across legal entities
- Companies with CRM/HRMS/ERP that need blended reporting
- Regulated industries that must retain 5-10 years of financial history for audit
- Businesses with data science needs requiring SQL Server ML Services or Python integration
Complete Architecture Blueprint
Before writing a single line of ETL code, understand the complete data flow. Every component has a specific role, and missing any layer creates fragility.
The End-to-End Data Flow
The 4 SQL Server Schemas
Why 4 Schemas?
- stg: Raw landing zone — exact copy of Tally data, no transformations. If ETL fails, you can inspect what came in.
- dw: Cleaned, conformed, and modeled for analytics. Surrogate keys, SCDs, fact tables, dimensions.
- ctl: Control and audit tables — watermarks, run history, logs, reconciliation results, data quality metrics.
- rpt: Reporting views — pre-built queries that business users can consume. Combine business logic + aggregations.
Hardware & Sizing
| Tally Size | SQL Server Spec | Storage |
|---|---|---|
| Small (< 100k vouchers) | SQL Express · 4 cores · 8 GB RAM | 50 GB |
| Medium (100k-1M vouchers) | SQL Standard · 8 cores · 32 GB RAM | 200 GB SSD |
| Large (1M-10M vouchers) | SQL Standard · 16 cores · 64 GB RAM | 500 GB SSD + backup |
| Enterprise (10M+ vouchers) | SQL Enterprise · 32+ cores · 128+ GB RAM | 1 TB+ NVMe SSD |
ODBC Extraction from TallyPrime
ODBC is the extraction channel. Before writing any ETL code, set up ODBC properly and understand what tables Tally exposes.
Prerequisites — Enable ODBC in Tally
- Open TallyPrime with the company loaded
- Press F1 → Settings → Connectivity
- Set Tally.NET Server to Both (Client and Server)
- Set Port to 9000
- Set Enable ODBC Server to Yes
- Press Ctrl+A to save and restart Tally
Install and Configure the DSN
- Open Windows ODBC Data Source Administrator (64-bit)
- Choose System DSN tab
- Click Add → Select Tally ODBC Driver
- Name:
TallyProd - Server: Tally server IP or hostname
- Port: 9000
- Click Test Connection — should show "Connection successful"
- Save the DSN
Test Extraction with PowerShell
# PowerShell — Test ODBC extraction from Tally $dsn = "DSN=TallyProd" $conn = New-Object System.Data.Odbc.OdbcConnection $conn.ConnectionString = $dsn $conn.Open() # Get count of ledgers $cmd = $conn.CreateCommand() $cmd.CommandText = "SELECT COUNT(*) FROM Ledger" $count = $cmd.ExecuteScalar() Write-Host "Ledger count: $count" # Get first 5 vouchers $cmd.CommandText = "SELECT TOP 5 Date, VoucherNumber, Amount FROM Voucher" $reader = $cmd.ExecuteReader() while ($reader.Read()) { Write-Host $reader["Date"] $reader["VoucherNumber"] $reader["Amount"] } $conn.Close()
Key Tally ODBC Tables for Extraction
| Table | Purpose | Typical Rows |
|---|---|---|
Company | Company details | 1-10 |
Ledger | Chart of accounts | 100-5,000 |
Group | Account groups | 50-500 |
StockItem | Products | 100-50,000 |
StockGroup | Product categories | 20-500 |
Voucher | Transactions | 10k-10M+ |
VoucherLedger | Ledger entries within vouchers | 30k-30M+ |
VoucherInventory | Inventory entries | 20k-20M+ |
VoucherBill | Bill allocations | 10k-10M+ |
CostCentre | Cost centres | 10-500 |
Optimizing ODBC Extraction Queries
- Always filter by date: Never extract all vouchers — filter by
WHERE Date >= '2026-04-01' - Use TOP for testing: Test extraction with
SELECT TOP 1000before running the full extraction - Batch by date: Extract vouchers in monthly or weekly batches to avoid timeouts
- Fetch only needed columns: Avoid
SELECT *— specify the columns you need - Extract during off-peak: Run full extraction after business hours to avoid impacting users
- Cache master data: Ledgers, stock items, and groups change rarely — extract once per day, not every run
Ledger and StockItem extraction is fast (seconds). VoucherLedger and VoucherInventory are slow (minutes per 100k rows). Plan your extraction strategy around this: masters daily, transactions incrementally.
Designing the Staging Schema
The staging schema is where raw Tally data lands before transformation. Design it as a faithful mirror of Tally's tables — no cleanup, no business logic, no denormalization. Transformation happens later.
Staging Table Design Principles
- Mirror Tally columns: Same names, same types (with adjustments)
- Add audit columns:
EtlBatchId,LoadedAt,SourceSystem - Use NVARCHAR generously: Tally strings can be long and Unicode — use NVARCHAR(255) or NVARCHAR(MAX)
- Store amounts as DECIMAL(18,4): Avoid FLOAT — financial data needs exact precision
- Store dates as DATE: Not strings — enables SQL Server date functions
- No primary keys initially: Let transformation handle uniqueness — staging is a raw landing zone
- Add clustered index on load date: Enables fast truncation of old batches
Example Staging Table — stg.Ledger
CREATE TABLE stg.Ledger ( -- Audit columns EtlBatchId BIGINT NOT NULL, LoadedAt DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(), SourceCompany NVARCHAR(200) NULL, -- Tally columns LedgerName NVARCHAR(255) NOT NULL, ParentGroup NVARCHAR(255) NULL, ClosingBalance DECIMAL(18,4) NULL, OpeningBalance DECIMAL(18,4) NULL, GSTRegNumber NVARCHAR(50) NULL, Address NVARCHAR(500) NULL, Phone NVARCHAR(50) NULL, Email NVARCHAR(200) NULL, CreditLimit DECIMAL(18,4) NULL, CreditDays INT NULL, IsBillWiseOn BIT NULL ); CREATE CLUSTERED INDEX IX_stg_Ledger_Batch ON stg.Ledger (EtlBatchId); CREATE NONCLUSTERED INDEX IX_stg_Ledger_Name ON stg.Ledger (LedgerName);
Example Staging Table — stg.Voucher
CREATE TABLE stg.Voucher ( EtlBatchId BIGINT NOT NULL, LoadedAt DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(), SourceCompany NVARCHAR(200) NULL, -- Tally voucher columns VoucherDate DATE NOT NULL, VoucherNumber NVARCHAR(50) NULL, VoucherTypeName NVARCHAR(100) NOT NULL, PartyLedgerName NVARCHAR(255) NULL, Amount DECIMAL(18,4) NULL, Narration NVARCHAR(MAX) NULL, Reference NVARCHAR(100) NULL, IsCancelled BIT NULL, IsOptional BIT NULL ); CREATE CLUSTERED INDEX IX_stg_Voucher_Batch ON stg.Voucher (EtlBatchId); CREATE NONCLUSTERED INDEX IX_stg_Voucher_Date ON stg.Voucher (VoucherDate, VoucherTypeName);
Control Schema — Tracking ETL Runs
CREATE TABLE ctl.EtlRun ( RunId BIGINT IDENTITY(1,1) PRIMARY KEY, StartedAt DATETIME2(3) NOT NULL, EndedAt DATETIME2(3) NULL, Status NVARCHAR(20) NOT NULL, -- Running/Success/Failed/Partial RowCount BIGINT NULL, ErrorMessage NVARCHAR(MAX) NULL, ExtractedFrom DATETIME2(3) NULL, -- Watermark start ExtractedTo DATETIME2(3) NULL -- Watermark end ); CREATE TABLE ctl.Watermark ( Entity NVARCHAR(100) NOT NULL PRIMARY KEY, LastValue DATETIME2(3) NULL, LastRunId BIGINT NULL, UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME() );
Python ETL Pipeline
Python is the fastest way to build a Tally ETL pipeline. With pyodbc (extract from Tally) and pandas + SQLAlchemy (load into SQL Server), you can build a production pipeline in a day.
Project Structure
tally_etl/ │ ├── config/ │ └── config.yaml # DSN names, connections, batch sizes ├── extractors/ │ ├── base.py # Common extraction logic │ ├── master.py # Ledger, StockItem, Group │ └── transaction.py # Voucher, VoucherLedger, VoucherInventory ├── loaders/ │ └── sqlserver.py # Bulk insert into staging ├── controls/ │ └── watermark.py # Watermark management ├── logs/ ├── main.py # Orchestrator └── requirements.txt
Install Dependencies
pip install pyodbc pandas sqlalchemy pyyaml python-dotenv
Configuration File
# config.yaml tally: dsn: TallyProd company: "ABC Ltd." batch_size: 5000 sqlserver: driver: "ODBC Driver 18 for SQL Server" server: "localhost" database: TallyDW trusted_connection: true bulk_batch_size: 10000 entities: masters: [Ledger, StockItem, StockGroup, Group, CostCentre] transactions: [Voucher, VoucherLedger, VoucherInventory, VoucherBill]
Main ETL Orchestrator
import pyodbc import pandas as pd from sqlalchemy import create_engine from datetime import datetime, timedelta import yaml import logging def load_config(path="config/config.yaml"): with open(path) as f: return yaml.safe_load(f) def get_tally_connection(cfg): return pyodbc.connect(f"DSN={cfg['tally']['dsn']}") def get_sqlserver_engine(cfg): c = cfg['sqlserver'] conn_str = ( f"mssql+pyodbc://{c['server']}/{c['database']}" f"?driver={c['driver']}&trusted_connection=yes" ) return create_engine(conn_str, fast_executemany=True) def log_etl_start(engine, source_count, extracted_to): sql = """ INSERT INTO ctl.EtlRun (StartedAt, Status, RowCount, ExtractedTo) OUTPUT INSERTED.RunId VALUES (SYSUTCDATETIME(), 'Running', ?, ?) """ with engine.begin() as conn: return conn.execute(sql, source_count, extracted_to).scalar() def extract_incremental(tally_conn, entity, watermark): # Entity-specific extraction logic if entity == "Voucher": sql = """ SELECT Date, VoucherNumber, VoucherTypeName, PartyLedgerName, Amount, Narration, Reference FROM Voucher WHERE Date > ? ORDER BY Date """ elif entity == "Ledger": sql = """ SELECT Name, Parent, ClosingBalance, OpeningBalance, GSTRegistrationNumber, Address, Phone FROM Ledger """ else: raise ValueError(f"Unknown entity: {entity}") if entity == "Voucher": return pd.read_sql(sql, tally_conn, params=[watermark]) else: return pd.read_sql(sql, tally_conn) def load_to_staging(engine, df, table_name, batch_id, company): df['EtlBatchId'] = batch_id df['SourceCompany'] = company df.to_sql( name=table_name, schema='stg', con=engine, if_exists='append', index=False, method=None, chunksize=10000 ) def run_etl(): logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') cfg = load_config() tally_conn = get_tally_connection(cfg) engine = get_sqlserver_engine(cfg) # Get last watermark watermark = pd.read_sql( "SELECT LastValue FROM ctl.Watermark WHERE Entity = 'Voucher'", engine ).iloc[0, 0] extracted_to = datetime.utcnow().date() logging.info(f"Extracting vouchers since {watermark}") run_id = log_etl_start(engine, 0, extracted_to) logging.info(f"EtlRunId: {run_id}") # Extract and load masters for entity in cfg['entities']['masters']: logging.info(f"Extracting {entity}") df = extract_incremental(tally_conn, entity, None) load_to_staging(engine, df, entity, run_id, cfg['tally']['company']) logging.info(f"Loaded {len(df)} rows for {entity}") # Extract and load transactions (incremental) df_v = extract_incremental(tally_conn, "Voucher", watermark) load_to_staging(engine, df_v, "Voucher", run_id, cfg['tally']['company']) logging.info(f"Loaded {len(df_v)} vouchers") # Update watermark with engine.begin() as conn: conn.execute( "UPDATE ctl.Watermark SET LastValue = ?, LastRunId = ?, UpdatedAt = SYSUTCDATETIME() WHERE Entity = 'Voucher'", extracted_to, run_id ) conn.execute( "UPDATE ctl.EtlRun SET EndedAt = SYSUTCDATETIME(), Status = 'Success', RowCount = ? WHERE RunId = ?", len(df_v), run_id ) logging.info("ETL completed successfully") if __name__ == "__main__": run_etl()
Python ETL Advantages
- Easy to test with pytest
- Cross-platform
- Rich library ecosystem
- Custom error handling
- Git-friendly source control
- Team knows Python
- Multiple data sources (Tally + CRM + API)
- Complex transformations
- Cloud deployment (Docker/Kubernetes)
- Machine learning downstream
SSIS ETL — The Microsoft-Native Option
SSIS (SQL Server Integration Services) is the ETL engine that ships with SQL Server. It offers a visual designer, native scheduling via SQL Server Agent, and tight integration with SQL Server security. For Microsoft-centric teams, SSIS is the natural choice.
SSIS vs Python — When to Choose SSIS
| Dimension | SSIS | Python |
|---|---|---|
| Learning Curve | Moderate (Visual) | Moderate (Code) |
| Visual Designer | Yes | No |
| Version Control | Difficult (binary .dtsx) | Easy (text .py) |
| Testing | Manual | Automated (pytest) |
| Scheduling | SQL Agent (built-in) | External (Task Scheduler, Airflow) |
| Error Handling | Good (event handlers) | Excellent (try/except) |
| Cross-Platform | Windows only | Cross-platform |
| Best For | Microsoft-centric teams | Modern, flexible teams |
SSIS Package Structure
-- SSIS Package: TallyDailyETL.dtsx -- Control Flow: 1. Execute SQL Task: Get last watermark 2. Data Flow Task: Extract Ledger → stg.Ledger 3. Data Flow Task: Extract StockItem → stg.StockItem 4. Data Flow Task: Extract Voucher → stg.Voucher 5. Data Flow Task: Extract VoucherLedger → stg.VoucherLedger 6. Execute SQL Task: Update watermark 7. Execute SQL Task: Log ETL run 8. Send Mail Task: Notify on failure -- Each Data Flow Task: a. OLE DB Source (Tally ODBC) b. Data Conversion (if needed) c. Derived Column (add EtlBatchId, LoadedAt) d. OLE DB Destination (stg table)
Configuring SSIS ODBC Source
- Open Visual Studio with SQL Server Data Tools (SSDT)
- Create a new Integration Services Project
- Add a Data Flow Task to the Control Flow
- Inside the Data Flow, add an ODBC Source component
- Configure the ODBC Source:
- Connection Manager: Create ODBC connection to
TallyProdDSN - Data Access Mode: SQL Command
- SQL Command Text: Your extraction query
- Connection Manager: Create ODBC connection to
- Add an OLE DB Destination pointing to SQL Server
- Map columns between source and destination
- Configure error output to redirect failures to a log table
SSIS ODBC Extraction Query
-- SSIS ODBC Source SQL — Voucher extraction with watermark SELECT Date AS VoucherDate, VoucherNumber, VoucherTypeName, PartyLedgerName, Amount, Narration, Reference FROM Voucher WHERE Date > ? ORDER BY Date
Scheduling with SQL Server Agent
-- Create SQL Agent Job for daily ETL USE msdb; EXEC dbo.sp_add_job @job_name = 'TallyDailyETL', @enabled = 1, @description = 'Extract Tally data into staging'; EXEC dbo.sp_add_jobstep @job_name = 'TallyDailyETL', @step_name = 'Run SSIS Package', @subsystem = 'SSIS', @command = '/FILE "C:\SSIS\TallyDailyETL.dtsx" /CONFIGFILE "C:\SSIS\prod.dtsConfig"', @on_success_action = 1, @on_fail_action = 2; -- Quit with failure -- Schedule: Daily at 2 AM EXEC dbo.sp_add_schedule @schedule_name = 'DailyAt2AM', @freq_type = 4, -- Daily @freq_interval = 1, @active_start_time = 020000; -- 2:00 AM EXEC dbo.sp_attach_schedule @job_name = 'TallyDailyETL', @schedule_name = 'DailyAt2AM';
Incremental Load Strategies
Extracting every voucher every day is wasteful — for a 5-million-row company, that is 5 million rows daily to refresh 100 new ones. Incremental load extracts only what changed since the last run.
Full Load vs Incremental Load
| Dimension | Full Load | Incremental Load |
|---|---|---|
| Extraction Time | Slow (hours) | Fast (minutes) |
| Load on Tally | High | Low |
| Storage | Truncate + reload | Append / upsert |
| Handles Deletes | Yes (implicit) | Needs change tracking |
| Best For | Masters (small) | Transactions (large) |
| Complexity | Low | Moderate |
The Watermark Pattern
Watermark Implementation in SQL Server
-- Read current watermark SELECT LastValue FROM ctl.Watermark WHERE Entity = 'Voucher'; -- After successful load, update watermark UPDATE ctl.Watermark SET LastValue = @NewHighWatermark, LastRunId = @RunId, UpdatedAt = SYSUTCDATETIME() WHERE Entity = 'Voucher';
Handling Deletes and Updates
Tally's ODBC interface does not expose a native "changed since" flag. Handling updates and deletes requires one of three strategies:
- Overlapping window: Extract the last 7 days of data every run. Handles updates but misses deletes older than 7 days.
- Full monthly reload: Once a month, extract the entire year to catch any missed changes.
- Edit Log extraction: Parse Tally's Edit Log (via XML) to identify altered or deleted vouchers. More complex but precise.
The 7-Day Overlap Pattern
-- Extract last 7 days instead of just new data -- Catches recent edits and same-week changes SELECT * FROM Voucher WHERE Date >= DATEADD(day, -7, @LastWatermark) ORDER BY Date;
The Full Monthly Reconciliation Run
- Daily: Incremental load with 7-day overlap
- Weekly (Sunday): Full reload of current month's transactions
- Monthly: Full reload of current financial year
- Quarterly: Full reload of all history (catches any drift)
- During reload: Diff against existing data to detect anomalies
VoucherNumber + VoucherDate + VoucherType. This ensures that re-running the ETL does not create duplicates. Alternatively, use MERGE for upsert logic.
Star Schema Design
The staging tables hold raw data. The star schema reorganizes that data for analytics. Star schemas power every modern BI tool — Power BI, Tableau, Looker, and custom dashboards.
Star Schema Basics
- Fact Table: Holds measurable events. Rows = transactions. Columns = measures (amount, quantity) + foreign keys to dimensions.
- Dimension Table: Holds descriptive attributes. Rows = entities (customers, products, dates). Columns = attributes (name, group, category).
- Star: One fact table surrounded by dimension tables. Simple joins, fast queries.
- Snowflake: Dimensions normalized into sub-dimensions. More complex, rarely needed for Tally.
The Tally Star Schema
Building DimDate — The Foundation
-- Generate DimDate for 10 years (2025-2035) DECLARE @Start DATE = '2025-01-01'; DECLARE @End DATE = '2035-12-31'; WITH DateSeries AS ( SELECT @Start AS FullDate UNION ALL SELECT DATEADD(day, 1, FullDate) FROM DateSeries WHERE FullDate < @End ) INSERT INTO dw.DimDate ( DateKey, FullDate, Year, Quarter, Month, MonthName, DayOfWeek, FiscalYear ) SELECT CAST(FORMAT(FullDate, 'yyyyMMdd') AS INT) AS DateKey, FullDate, YEAR(FullDate) AS Year, DATEPART(quarter, FullDate) AS Quarter, MONTH(FullDate) AS Month, DATENAME(month, FullDate) AS MonthName, DATENAME(weekday, FullDate) AS DayOfWeek, CASE WHEN MONTH(FullDate) >= 4 THEN YEAR(FullDate) ELSE YEAR(FullDate) - 1 END AS FiscalYear FROM DateSeries OPTION (MAXRECURSION 0);
Building FactVoucherLedger from Staging
-- Transform staging into fact table with dimension lookups INSERT INTO dw.FactVoucherLedger ( DateKey, LedgerKey, VoucherTypeKey, CompanyKey, VoucherNumber, DebitAmount, CreditAmount ) SELECT CAST(FORMAT(sv.VoucherDate, 'yyyyMMdd') AS INT) AS DateKey, dl.LedgerKey, dvt.VoucherTypeKey, dc.CompanyKey, sv.VoucherNumber, CASE WHEN svl.IsDeemedPositive = 1 THEN svl.Amount ELSE 0 END AS DebitAmount, CASE WHEN svl.IsDeemedPositive = 0 THEN ABS(svl.Amount) ELSE 0 END AS CreditAmount FROM stg.VoucherLedger svl JOIN stg.Voucher sv ON svl.VoucherNumber = sv.VoucherNumber AND svl.VoucherDate = sv.VoucherDate AND svl.EtlBatchId = sv.EtlBatchId LEFT JOIN dw.DimLedger dl ON svl.LedgerName = dl.LedgerName LEFT JOIN dw.DimVoucherType dvt ON sv.VoucherTypeName = dvt.VoucherTypeName LEFT JOIN dw.DimCompany dc ON svl.SourceCompany = dc.CompanyName WHERE svl.EtlBatchId = @CurrentBatchId;
• Simple joins: Fact to Dim = one hop. Fast queries.
• Aggregation-friendly: Power BI and Tableau optimize star schemas automatically.
• Time intelligence: DimDate enables YTD, MTD, QTD, YoY calculations.
• Scalable: Billions of rows in fact tables handled easily with proper indexes.
• Business-friendly: Dimension attributes map to what analysts think about.
Data Quality Checks
Bad data in, bad dashboards out. Data quality checks validate every batch of extracted data before it is transformed into the warehouse. This catches extraction issues early, when they are cheap to fix.
The 8 Data Quality Checks
| Check | What It Validates | Action on Failure |
|---|---|---|
| Row Count Delta | New rows vs expected range | Alert if zero or 10x normal |
| Null Check | Required columns not null | Reject rows with null keys |
| Duplicate Check | No duplicate voucher numbers within batch | Investigate source |
| Referential Integrity | All FKs resolve to dimensions | Load unknown members into dims |
| Range Check | Amounts within expected bounds | Flag outliers for review |
| Date Validity | Dates within fiscal range | Reject out-of-range dates |
| Balance Check | Debits = Credits per voucher | Flag unbalanced vouchers |
| Completeness | All expected vouchers present | Compare to Tally count |
Data Quality Check Script
-- Data quality checks after ETL load DECLARE @BatchId BIGINT = @CurrentBatchId; -- Check 1: Row count sanity DECLARE @RowCount INT; SELECT @RowCount = COUNT(*) FROM stg.Voucher WHERE EtlBatchId = @BatchId; IF @RowCount = 0 INSERT INTO ctl.DataQuality (BatchId, CheckName, Status, Details) VALUES (@BatchId, 'RowCountNonZero', 'FAIL', 'Zero rows extracted'); -- Check 2: Unbalanced vouchers INSERT INTO ctl.DataQuality (BatchId, CheckName, Status, Details) SELECT @BatchId, 'VoucherBalance', 'FAIL', 'Voucher ' + VoucherNumber + ' not balanced' FROM ( SELECT VoucherNumber, SUM(Amount) AS Total FROM stg.VoucherLedger WHERE EtlBatchId = @BatchId GROUP BY VoucherNumber HAVING ABS(SUM(Amount)) > 0.01 ) t; -- Check 3: Orphan ledger references INSERT INTO ctl.DataQuality (BatchId, CheckName, Status, Details) SELECT DISTINCT @BatchId, 'OrphanLedgers', 'WARN', 'Missing ledger: ' + svl.LedgerName FROM stg.VoucherLedger svl LEFT JOIN stg.Ledger sl ON svl.LedgerName = sl.LedgerName WHERE svl.EtlBatchId = @BatchId AND sl.LedgerName IS NULL;
The Data Quality Scorecard
Tally vs SQL Server Reconciliation
The data warehouse is only valuable if it matches Tally. Reconciliation compares totals between Tally and SQL Server on the same date range. Any mismatch triggers investigation. This is non-negotiable for a production warehouse.
The 10 Reconciliation Checks
| # | Check | Frequency | Threshold |
|---|---|---|---|
| 1 | Voucher count by type | Daily | 0 difference |
| 2 | Total sales amount | Daily | < ৳1 |
| 3 | Total purchase amount | Daily | < ৳1 |
| 4 | Total receivables | Daily | < ৳1 |
| 5 | Total payables | Daily | < ৳1 |
| 6 | Closing stock value | Daily | < ৳10 |
| 7 | Ledger count | Weekly | 0 difference |
| 8 | Stock item count | Weekly | 0 difference |
| 9 | GST liability | Weekly | < ৳1 |
| 10 | Trial balance sum | Monthly | < ৳1 |
Reconciliation SQL Script
-- Reconcile sales totals between Tally and SQL Server -- Run via ODBC against Tally, then compare with SQL Server -- Step 1: Get total from Tally (via ODBC) SELECT SUM(Amount) AS TallyTotal FROM Voucher WHERE VoucherTypeName = 'Sales' AND Date BETWEEN '2026-09-01' AND '2026-09-30'; -- Step 2: Get total from SQL Server warehouse SELECT SUM(DebitAmount - CreditAmount) AS SqlTotal FROM dw.FactVoucherLedger f JOIN dw.DimDate d ON f.DateKey = d.DateKey JOIN dw.DimVoucherType vt ON f.VoucherTypeKey = vt.VoucherTypeKey WHERE vt.VoucherTypeName = 'Sales' AND d.FullDate BETWEEN '2026-09-01' AND '2026-09-30'; -- Step 3: Log result INSERT INTO ctl.Reconcile (CheckDate, CheckName, TallyValue, SqlValue, Difference, Status) VALUES ( CAST(GETDATE() AS DATE), 'SalesTotal_September', @TallyTotal, @SqlTotal, ABS(@TallyTotal - @SqlTotal), CASE WHEN ABS(@TallyTotal - @SqlTotal) < 1 THEN 'PASS' ELSE 'FAIL' END );
Automated Reconciliation Workflow
Root Cause Analysis for Mismatches
- Missing data: ETL failed to extract some vouchers → check watermark and batch logs
- Duplicates: Same voucher loaded twice → check idempotency logic
- Deletes not propagated: Tally deleted a voucher but SQL Server still has it → need full reload
- Time zone issues: Voucher dates shifted by timezone → store dates as Tally dates, not UTC
- Rounding: Decimal precision mismatch → use DECIMAL(18,4) everywhere
- Filter mismatch: Reconciliation query filters differently than ETL → align filters
- Cancelled vouchers: ETL extracts cancelled vouchers but reconciliation excludes → align filter
A CFO making decisions from a dashboard that is 0.5% off could be making a ৳50 lakh decision on wrong data. Reconciliation is the control that makes the dashboard trustworthy. Without it, you are running the business on hope.
Scheduling & Orchestration
An ETL pipeline is only as good as its schedule. Manual runs are error-prone; scheduled runs with monitoring give you reliability.
Scheduling Options Comparison
| Tool | Best For | Complexity | Cost |
|---|---|---|---|
| Windows Task Scheduler | Simple Python scripts | Low | Free |
| SQL Server Agent | SSIS packages | Low | Included |
| Apache Airflow | Complex DAGs | High | Free |
| Prefect / Dagster | Modern Python | Medium | Freemium |
| Azure Data Factory | Cloud-first | Medium | Pay-per-use |
Windows Task Scheduler Setup for Python
# PowerShell — Create a scheduled task for Python ETL $action = New-ScheduledTaskAction ` -Execute "C:\Python311\python.exe" ` -Argument "C:\TallyETL\main.py" ` -WorkingDirectory "C:\TallyETL" $trigger = New-ScheduledTaskTrigger ` -Daily ` -At "02:00AM" $principal = New-ScheduledTaskPrincipal ` -UserId "DOMAIN\svc_tallyetl" ` -LogonType Password ` -RunLevel Highest $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RestartCount 3 ` -RestartInterval (New-TimeSpan -Minutes 10) Register-ScheduledTask ` -TaskName "TallyDailyETL" ` -Action $action ` -Trigger $trigger ` -Principal $principal ` -Settings $settings
Airflow DAG for Tally ETL
from airflow import DAG from airflow.operators.python import PythonOperator from airflow.operators.email import EmailOperator from datetime import datetime, timedelta default_args = { 'owner': 'data-team', 'retries': 3, 'retry_delay': timedelta(minutes=10), 'email_on_failure': True, 'email': ['data-team@company.com'], } with DAG( 'tally_etl_daily', default_args=default_args, description='Extract Tally data into SQL Server', schedule_interval='0 2 * * *', # 2 AM daily start_date=datetime(2026, 1, 1), catchup=False, tags=['tally', 'etl'], ) as dag: extract_masters = PythonOperator( task_id='extract_masters', python_callable=extract_masters_fn, ) extract_transactions = PythonOperator( task_id='extract_transactions', python_callable=extract_transactions_fn, ) run_dq_checks = PythonOperator( task_id='run_dq_checks', python_callable=run_dq_checks_fn, ) run_reconciliation = PythonOperator( task_id='run_reconciliation', python_callable=run_reconciliation_fn, ) extract_masters >> extract_transactions >> run_dq_checks >> run_reconciliation
Monitoring & Alerting
- Success/failure email after every run (not just failures — you need to know it ran)
- Duration alerts: If ETL takes more than 2x normal time, alert
- Row count alerts: If extracted rows drop 50% from yesterday, alert
- Reconciliation alerts: Any FAIL on reconciliation triggers immediate notification
- Dashboard: A simple Power BI dashboard showing ETL run history, duration trends, and success rate
- On-call rotation: Someone must respond to failures 24/7 if your business is global
Performance Optimization
SQL Server is fast by default, but Tally warehouses can grow to millions of rows. Proper indexing, partitioning, and columnstore indexes are essential for keeping dashboards fast.
The Performance Stack
Columnstore Index on Fact Table
-- Columnstore index for fast aggregations on fact table CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactVoucherLedger ON dw.FactVoucherLedger; -- Benefit: aggregations scan 10-100x faster than rowstore -- Compression: 5-10x reduction in storage -- Trade-off: slower single-row inserts -- Mitigation: use staging to batch-load, then rebuild CCI
Partitioning by Fiscal Year
-- Partition function by fiscal year starting April 1 CREATE PARTITION FUNCTION pf_FiscalYear (INT) AS RANGE RIGHT FOR VALUES ( 20250401, 20260401, 20270401, 20280401, 20290401, 20300401 ); -- Partition scheme on PRIMARY filegroup CREATE PARTITION SCHEME ps_FiscalYear AS PARTITION pf_FiscalYear ALL TO ([PRIMARY]); -- Apply to fact table CREATE CLUSTERED INDEX CCI_FactVoucherLedger ON dw.FactVoucherLedger (DateKey) WITH (DROP_EXISTING = ON) ON ps_FiscalYear (DateKey); -- To switch out old partition (archive): ALTER TABLE dw.FactVoucherLedger SWITCH PARTITION 2 TO dw.FactVoucherLedger_Archive;
Query Performance Benchmarks
The 7 Performance Rules
- Use columnstore on fact tables — the single biggest performance win for aggregations
- Partition by fiscal year — enables fast archival and parallel scans
- Update statistics regularly — SQL Server relies on stats for query plans
- Index foreign keys in fact tables — required for efficient joins
- Pre-aggregate common reports — monthly summaries, top customers, ageing buckets
- Use views, not tables, for reporting — flexible, no storage overhead
- Regular index maintenance — rebuild fragmented indexes weekly
Power BI Consumption from SQL Server
This is where all the ETL work pays off. Power BI connects to SQL Server — not to Tally. Dashboards refresh in seconds, support hundreds of users, and can include historical data going back years.
Connecting Power BI to SQL Server
- Open Power BI Desktop
- Click Get Data → SQL Server Database
- Enter server name and database (
TallyDW) - Choose Import mode (not DirectQuery — Import is faster for star schemas)
- Select the fact and dimension tables
- Create relationships in the model view
- Build measures using DAX
- Publish to Power BI Service
Recommended Data Model
- Load FactVoucherLedger and FactVoucherInventory as facts
- Load all dimensions: DimDate, DimLedger, DimStockItem, DimVoucherType, DimCompany, DimCostCentre
- Create relationships from each fact to each dimension (many-to-one)
- Mark DimDate as a Date Table for time intelligence
- Hide technical keys (
DateKey,LedgerKey) — show only business columns - Set sort order for Month and DayOfWeek (use sort-by-column)
Essential DAX Measures
// Total Sales (Debit - Credit for Sales vouchers) Total Sales = CALCULATE( SUM( FactVoucherLedger[CreditAmount] ), DimVoucherType[VoucherTypeName] = "Sales" ) // YTD Sales Sales YTD = TOTALYTD( [Total Sales], DimDate[FullDate] ) // Year-over-Year Growth Sales YoY % = VAR CurrentSales = [Total Sales] VAR PriorYearSales = CALCULATE( [Total Sales], SAMEPERIODLASTYEAR( DimDate[FullDate] ) ) RETURN DIVIDE(CurrentSales - PriorYearSales, PriorYearSales) // Total Receivables Receivables = CALCULATE( SUM( FactVoucherLedger[DebitAmount] - FactVoucherLedger[CreditAmount] ), DimLedger[IsDebtor] = TRUE ) // Aged Receivables (90+ days) Receivables 90+ = CALCULATE( [Receivables], FILTER( DimDate, DimDate[FullDate] <= TODAY() - 90 ) )
Scheduled Refresh Architecture
Refresh Scheduling in Power BI Service
- Gateway: Install On-Premises Data Gateway on a server with SQL Server access
- Frequency: Up to 8 times per day (Pro) or 48 times (Premium)
- Recommended: Once daily after ETL completes, plus one afternoon refresh for intra-day data
- Incremental refresh: For fact tables with millions of rows, use Power BI incremental refresh to load only new partitions
- Refresh notifications: Set email alerts on refresh failures
• CFO: Live P&L, balance sheet, cash flow dashboard — always current
• Sales Manager: Real-time customer ageing, top customer analysis
• Operations: Stock aging, negative stock alerts, supplier performance
• Tax: GST liability, input credit position, ready for monthly filing
• Board: Multi-company consolidated dashboards — updated daily
Total refresh window: 90 minutes nightly. Users get fresh data every morning.
Frequently Asked Questions
Standard is enough for most Tally warehouses. Enterprise is only required if you need:
- Table partitioning with >15,000 partitions
- Columnstore with high concurrency at scale
- Always On Availability Groups
- Advanced security (Transparent Data Encryption with EKM)
- More than 128 GB RAM
For 99% of Tally warehouses, Standard is more than sufficient.
Technically yes, but strongly discouraged. Staging provides:
- Idempotent reloads without re-querying Tally
- Easier debugging when transformations fail
- Isolation from source system failures
- Ability to re-run transformation with fixed logic
The cost of staging (extra storage) is trivial compared to the benefit.
Typical first-year investment:
- SQL Server Standard license: ৳3-8 lakh (or use SQL Express for free if <10 GB)
- Server hardware: ৳4-15 lakh (on-premise) or ৳1-3 lakh/year (cloud)
- Development (ETL + warehouse): ৳6-20 lakh
- Power BI licenses: ৳1-5 lakh/year
- Ongoing maintenance: ৳3-10 lakh/year
Total Year 1: ৳15-50 lakh depending on scale. Typical ROI is 12-24 months through better decisions, faster closes, and eliminated manual reporting.
Recommended schedule:
- Daily: Incremental load with 7-day overlap
- Weekly: Full reload of current month (catches missed changes)
- Monthly: Full reload of current fiscal year
- Quarterly: Full reload of all history
- On-demand: After major Tally changes (schema changes, company restructure)
Tally is always the source of truth. SQL Server is a copy. If they disagree:
- Trust Tally for accounting and compliance
- Investigate why SQL Server is wrong (usually ETL issue)
- Fix the ETL and reload the affected data
- Document the incident and root cause
- Add a data quality check to prevent recurrence
Yes, with caveats. Azure SQL is fully compatible with T-SQL, supports columnstore, and integrates with Power BI seamlessly. But:
- You need an On-Premises Data Gateway to extract from Tally (which is on-premise)
- Latency between Tally and Azure affects ETL duration
- Bandwidth usage for bulk loads can be significant
- Cost is pay-per-use — can be higher than on-premise for steady workloads
Hybrid works well: on-premise staging SQL, Azure SQL for the warehouse.
Add a CompanyKey dimension and populate it during ETL. Every fact table row carries the company key. Then:
- Per-company analytics: Filter by CompanyKey
- Group consolidation: Aggregate across all CompanyKeys
- Inter-company elimination: Handle in a reporting view with rules
- Multi-currency: Add CurrencyKey dimension if companies use different currencies
If your data residency requires storage within a specific country/region:
- Host SQL Server on-premise in the required country
- Use Azure SQL in the appropriate region (e.g., Azure Southeast Asia for Bangladesh)
- Ensure Power BI data is also hosted in-region (Power BI has regional deployments)
- Document data flow for GDPR / data residency audits
- Implement column-level encryption for PII (customer phone, email, address)
Learning Path for Tally + SQL Server
- Day 1-2: Understand the 4-tier architecture and why SQL Server sits between Tally and BI
- Day 3-4: Set up ODBC, test extraction, explore Tally ODBC tables
- Day 5-7: Design and create staging schema tables
- Day 8-12: Build the Python or SSIS extraction pipeline
- Day 13-15: Implement incremental loading with watermarks
- Day 16-20: Design and build star schema (dimensions + facts)
- Day 21-24: Add data quality checks and reconciliation
- Day 25-28: Schedule ETL, add monitoring and alerting
- Day 29-30: Connect Power BI, build dashboards, measure performance
Series Roadmap — 45 Parts to CFO-Level Mastery
You have completed Part 35 — the bridge between Tally data and enterprise analytics.
0 Comments
thanks for your comments!