Build Report · ERP / Automation

Sage 100 ProvideX ODBC
Developer Reference

Practical patterns for querying, automating, and extracting value from Sage 100 data. What works, what doesn't, and how to build reporting pipelines that run unattended.

Author Calvin Graham Environment Sage 100 / ProvideX ODBC Driver Tools PowerShell, Windows Task Scheduler Download Full reference .docx →

Overview

Sage 100 stores its data in a file-based ProvideX database. The ProvideX ODBC driver exposes this data through a SQL-like interface, letting you query it directly from PowerShell, Python, or any ODBC-capable environment — without going through the Sage reporting UI.

This opens up scheduled automated reports, real-time data pulls, cross-system integrations, and custom calculations that would be impractical inside Crystal Reports or Sage's built-in tools.

This document is based on production deployments. It documents observed behavior — not official Sage documentation. Some behavior may vary across Sage 100 versions.

Connection

PowerShell is the recommended environment. It's available on every Windows machine, requires no additional dependencies beyond the ODBC driver, and integrates directly with Task Scheduler for unattended execution.

PowerShell

powershell
$conn = New-Object System.Data.Odbc.OdbcConnection(
    "DSN=SOTAMAS90;UID=YourUser;PWD=YourPassword;COMPANY=COMPANY_FILE;"
)
$conn.Open()

$cmd         = $conn.CreateCommand()
$cmd.CommandText = $query
$adapter     = New-Object System.Data.Odbc.OdbcDataAdapter($cmd)
$ds          = New-Object System.Data.DataSet
$adapter.Fill($ds) | Out-Null

$conn.Close()

# Results are in $ds.Tables[0].Rows
foreach ($row in $ds.Tables[0].Rows) {
    Write-Host $row.CustomerNo $row.CustomerName
}

Python

python
import pyodbc

conn = pyodbc.connect(
    "DSN=SOTAMAS90;UID=YourUser;PWD=YourPassword;COMPANY=COMPANY_FILE;"
)
cursor = conn.cursor()
cursor.execute("SELECT CustomerNo, CustomerName FROM AR_Customer LIMIT 10")

for row in cursor.fetchall():
    print(row.CustomerNo, row.CustomerName)

conn.close()

Connection string anatomy

ParameterValueNotes
DSNSOTAMAS90Name of the System DSN you created in odbcad32. Any name works — this is convention.
UIDSage usernameSage 100 user with data read access
PWDSage passwordStore encrypted in production (see Task Scheduler section)
COMPANYCOMPANY_FILE, etc.Your Sage company code — required

SQL Rules

The ProvideX driver implements a subset of SQL that doesn't fully match any standard dialect. Knowing the quirks upfront saves a lot of debugging.

Key gotchas

Do not end queries with a semicolon. The driver rejects them. Also: no square brackets around field or table names — they're not required and may cause errors.
  • No semicolons at end of query
  • No square brackets around names
  • Read-only — UPDATE/INSERT/DELETE will fail
  • No ORDER BY — sort in your script
  • No OR operator — use IN() instead
  • AND in compound WHERE is unreliable — test carefully
  • No subqueries
  • No string functions (CONCAT, TRIM, SUBSTRING, etc.) — handle in script

Date syntax

Dates require the ODBC escape format. Standard ISO strings don't work.

sql
-- Exact date
WHERE InvoiceDate = {d'2025-01-15'}

-- Range
WHERE InvoiceDate BETWEEN {d'2025-01-01'} AND {d'2025-01-31'}

-- Greater than
WHERE InvoiceDate > {d'2025-02-12'}

-- PowerShell variable interpolation
$sql = "SELECT * FROM AR_InvoiceHistoryHeader WHERE InvoiceDate = {d'$dateStr'}"

Pagination

TOP is not supported. Use LIMIT and OFFSET.

sql
SELECT * FROM AR_Customer LIMIT 100
SELECT * FROM AR_Customer LIMIT 100 OFFSET 100   -- rows 101-200
SELECT * FROM AR_Customer LIMIT 100 OFFSET 200   -- rows 201-300

String queries

sql
-- LIKE (% wildcard works)
WHERE CustomerName LIKE 'Acme%'
WHERE CustomerName NOT LIKE '%test%'

-- IN instead of OR
WHERE CustomerNo IN ('0000001', '0000002', '0000003')

-- Numeric comparison
WHERE CustomerNo < '0000254'

-- Null checks
WHERE EmailAddress IS NOT NULL
WHERE InactiveReasonCode IS NULL

Function Support

All results from live production testing.

Function / ClauseStatusNotes / Workaround
COUNT, SUM, AVG, MAX, MINPassAll aggregate functions work normally
GROUP BY, HAVINGPassStandard behavior
DISTINCTPass
BETWEENPassWorks for dates and numerics
LIKE / NOT LIKEPass% wildcard supported
IN / NOT INPassUse IN instead of OR
IS NULL / IS NOT NULLPass
Date comparison {d}PassMust use ODBC escape syntax
Inline math (+, -, *, /)Pass(qty * price) AS ExtAmt works
LIMIT / OFFSETPassUse instead of TOP
INNER JOIN (IJ syntax)PassOne join per query. IJ is the most reliable join type.
ORDER BYFailSort in your script after retrieval
OR operatorFailUse IN()
AND in compound WHEREFailUnreliable — filter on one field in SQL, rest in script
SubqueriesFailNot supported
CASEFailImplement in script
CONCAT, UCASE, LCASE, SUBSTRING, TRIM, LEFT, REPLACE, LENGTHFailAll string functions unsupported — handle in script
ROUND, IFNULLFailHandle in script
COUNT(DISTINCT)Fail
UPDATE / INSERT / DELETEFailDriver is read-only by design

Joins

The ProvideX driver supports only one join per query, using a non-standard curly-brace syntax. IJ (Inner Join) is the most reliable and best-tested join type. Only use what you've verified in your environment.

Syntax

sql
SELECT A.Field1, B.Field2
FROM
    { IJ TableA A
      INNER JOIN TableB B
      ON A.KeyField = B.KeyField }
The curly brace wrapper and IJ prefix are required. Standard FROM TableA JOIN TableB syntax without the wrapper will fail.

Composite keys (header/detail tables)

Many Sage 100 header/detail relationships use composite keys. Include all key fields in the ON clause.

sql
SELECT
    H.InvoiceDate, H.InvoiceNo, H.CustomerNo,
    D.ItemCode, D.QuantityShipped, D.UnitPrice,
    (D.QuantityShipped * D.UnitPrice) AS ExtAmt
FROM
    { IJ AR_InvoiceHistoryHeader H
      INNER JOIN AR_InvoiceHistoryDetail D
      ON H.HeaderSeqNo = D.HeaderSeqNo AND H.InvoiceNo = D.InvoiceNo }
WHERE
    H.InvoiceDate BETWEEN {d'2025-01-01'} AND {d'2025-01-31'}

Multi-table pattern

When you need data from three or more tables, run separate queries and join in PowerShell using a hashtable keyed on the shared field. This is the standard pattern for anything requiring data from more than two tables.

powershell
# Query 1: the join you can do in SQL
$queryMain   = "SELECT D.VendorNo, D.ItemCode, H.CustomerNo, H.OrderDate
                FROM { IJ AR_InvoiceHistoryDetail D
                       INNER JOIN AR_InvoiceHistoryHeader H
                       ON D.InvoiceNo = H.InvoiceNo }
                WHERE H.OrderDate BETWEEN {d'$start'} AND {d'$end'}"

# Query 2: a separate table you can't join in SQL
$queryVendor = "SELECT VendorNo, VendorName FROM AP_Vendor"

# Execute both...

# Build a hashtable for O(1) lookup
$vendors = @{}
foreach ($v in $vendorTable.Rows) {
    $vendors[$v.VendorNo] = $v.VendorName
}

# Join in PowerShell
$results = foreach ($row in $mainTable.Rows) {
    [PSCustomObject]@{
        ItemCode   = $row.ItemCode
        CustomerNo = $row.CustomerNo
        VendorName = $vendors[$row.VendorNo]   # joined here
        OrderDate  = $row.OrderDate
    }
}

Script-Side Processing

Because ORDER BY, string functions, CASE, and ROUND don't work in ProvideX SQL, the practical pattern is: pull clean data with a tight WHERE clause, do everything else in your script.

Sorting

powershell
# Sort after retrieval — ORDER BY doesn't work in the driver
$results | Sort-Object InvoiceDate -Descending
$results | Sort-Object CustomerName, InvoiceDate

Calculated fields & rounding

powershell
# Inline math works in SQL, but ROUND() does not
# Option 1: let the spreadsheet round (usually fine)
# Option 2: round in PowerShell
$marginPct = [Math]::Round($row.MarginPercent, 2)

# Divide-by-zero guard (epsilon trick — works in SQL too)
# Add 1e-9 to denominator — rounds to 0 in any spreadsheet
# (D.QuantityShipped * D.UnitPrice + 1e-9)

String manipulation

powershell
# UCASE / LCASE don't exist in the driver — use PowerShell
$row.CustomerName.ToUpper()
$row.CustomerName.Trim()
$row.ItemCode.Substring(0, 3)

# CONCAT — use PowerShell string interpolation
"$($row.FirstName) $($row.LastName)"

Conditional logic (CASE replacement)

powershell
# CASE doesn't work in the driver — use a ternary or switch
$channel = switch -Regex ($row.WebOrderNo) {
    '^(1|[gG])'  { 'Web Channel 1' }
    '^(2|[sS])'  { 'Web Channel 2' }
    default       { 'Offline' }
}

# IFNULL — use null coalescing
$name = if ($row.VendorName) { $row.VendorName } else { 'Unknown' }

Export to Excel

powershell
# Install-Module ImportExcel  (no Excel required)
$results | Sort-Object InvoiceDate -Descending |
    Export-Excel -Path "C:\Reports\Sales_$(Get-Date -f yyyyMMdd).xlsx" `
                 -AutoSize -BoldTopRow -FreezeTopRow

Example Queries

Sales margin report

Inline math for extended amounts and margin %. The 1e-9 epsilon prevents divide-by-zero on zero-price lines.

sql
SELECT
    H.InvoiceDate, H.InvoiceNo, H.CustomerNo, H.BillToName,
    D.ItemCode, D.ItemCodeDesc, D.ProductLine,
    D.QuantityShipped AS Qty,
    D.UnitPrice,
    (D.QuantityShipped * D.UnitPrice)              AS ExtAmt,
    D.UnitCost,
    (D.QuantityShipped * D.UnitCost)               AS ExtCost,
    (
        (D.QuantityShipped * (D.UnitPrice - D.UnitCost))
        / (D.QuantityShipped * D.UnitPrice + 1e-9)
        * 100
    ) AS MarginPct
FROM
    { IJ AR_InvoiceHistoryHeader H
      INNER JOIN AR_InvoiceHistoryDetail D
      ON H.HeaderSeqNo = D.HeaderSeqNo AND H.InvoiceNo = D.InvoiceNo }
WHERE
    H.InvoiceDate BETWEEN {d'$startDate'} AND {d'$endDate'}

Open backorders

sql
SELECT
    H.SalesOrderNo, H.OrderDate, H.CustomerNo, H.BillToName,
    D.ItemCode, D.ItemCodeDesc,
    D.QuantityOrdered,
    D.QuantityShipped,
    (D.QuantityOrdered - D.QuantityShipped) AS QtyBackordered,
    D.UnitPrice
FROM
    { IJ SO_SalesOrderHeader H
      INNER JOIN SO_SalesOrderDetail D
      ON H.SalesOrderNo = D.SalesOrderNo }
WHERE
    D.QuantityOrdered > D.QuantityShipped

Commissions by salesperson

sql
SELECT
    SalespersonNo,
    SUM(CommissionAmt)  AS TotalCommission,
    SUM(SalesAmt)       AS TotalSales,
    COUNT(InvoiceNo)    AS InvoiceCount
FROM AR_SalespersonCommission
WHERE PeriodEndDate BETWEEN {d'2025-01-01'} AND {d'2025-12-31'}
GROUP BY SalespersonNo

AP open invoices

sql
SELECT
    VendorNo, VendorName, InvoiceNo,
    InvoiceDate, DueDate, InvoiceAmt, Balance
FROM AP_OpenInvoice
WHERE Balance > 0

Key Tables

Table names follow the pattern MODULE_Entity. Module prefix maps to the Sage 100 module.

TableModuleDescription
AR_CustomerARCustomer master — names, addresses, terms, salesperson
AR_InvoiceHistoryHeaderARPosted invoice headers — dates, totals, customer, shipping
AR_InvoiceHistoryDetailARPosted invoice line items — items, quantities, prices, costs
AR_OpenInvoiceARUnpaid AR invoices with balances
AR_SalespersonCommissionARCommission records by salesperson and period
SO_SalesOrderHeaderSOOpen/historical order headers
SO_SalesOrderDetailSOOrder lines with qty ordered, shipped, backordered
AP_VendorAPVendor master — names, addresses, terms
AP_OpenInvoiceAPOpen AP invoices with balances and due dates
AP_InvoiceHistoryHeaderAPPosted AP invoice headers
CI_ItemCIItem master — codes, descriptions, costs, quantities
CI_ItemWarehouseCIPer-warehouse quantity and cost data
IM_AliasItemIMAlternate item codes / vendor part numbers
PO_PurchaseOrderHeaderPOPO headers
PO_PurchaseOrderDetailPOPO lines with quantities ordered and received
Sage 100 has hundreds of tables. Use $conn.GetSchema("Tables") in PowerShell to enumerate all available tables for your company, or GetSchema("Columns", @($null, $null, "TableName", $null)) to inspect a specific table's fields.

ODBC Setup (Without Sage Client)

The ProvideX driver can be installed on any Windows machine without a full Sage client. Useful for dedicated report servers or task scheduler machines.

32-bit only. Use C:\Windows\SysWOW64\odbcad32.exe for the ODBC administrator, and all DLLs go into SysWOW64 — not System32. This is the most common source of installation failures.

Steps

  1. Copy Pvxio.dll, Pvxodbc.dll, and Pvxsec32.dll from C:\Windows\SysWOW64\ on a working Sage workstation to the same path on your target machine.
  2. Import the registry file below to register the driver. Update the serial key from your source workstation if needed.
  3. Install Visual C++ redistributables. Missing VC++ runtimes cause cryptic errors both when registering the driver and when running scripts. The TechPowerUp all-in-one installer covers everything from 2005 onward in one shot. Use Dependency Walker to diagnose missing DLLs if needed.
  4. Open C:\Windows\SysWOW64\odbcad32.exe, create a System DSN using MAS 90 4.0 ODBC Driver, and point it at your Sage data directory (e.g., \\server\MAS90\MAS_COMPANY_FILE).

Registry file

registry
Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\MAS 90 4.0 ODBC Driver]
"Driver"="PVXODBC.DLL"
"Setup"="PVXODBC.DLL"
"SQLLevel"="0"
"DriverODBCVer"="03.00"
"APILevel"="1"
"ConnectFunctions"="YYN"
"FileUsage"="1"
"UsageCount"="dword:0000000"

[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Sage Software\ProvideX ODBC Driver]
"Serial"="500001-1-5DE5C882FC4DBEB6"
"Name"="Registered MAS 90 & MAS 200 user"
"Company"="Sage Software, Inc."

[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\ODBC Drivers]
"MAS 90 4.0 ODBC Driver"="Installed"

Windows Task Scheduler

Task Scheduler is the right tool for running these scripts unattended. No additional dependencies — just PowerShell and the ODBC driver. The key capability: run whether or not a user is logged in.

Critical settings

Must configure

  • Run whether user is logged on or not
  • Run with highest privileges
  • -ExecutionPolicy Bypass -NonInteractive in arguments
  • Start when possible after missed trigger

Watch out for

  • Stored credentials expire when you change your Windows password
  • Scripts fail silently with no logged-in user — build CSV logging into every script
  • Plaintext credentials in script files — use encrypted PSCredential exports in prod

PowerShell task registration

powershell
$action = New-ScheduledTaskAction `
    -Execute "powershell.exe" `
    -Argument "-ExecutionPolicy Bypass -NonInteractive -File C:\Scripts\DailyReport.ps1"

$trigger   = New-ScheduledTaskTrigger -Daily -At "06:00AM"

$settings  = New-ScheduledTaskSettingsSet `
    -RunOnlyIfNetworkAvailable `
    -StartWhenAvailable `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 30)

$principal = New-ScheduledTaskPrincipal `
    -UserId "DOMAIN\YourUser" `
    -LogonType Password `
    -RunLevel Highest

Register-ScheduledTask `
    -TaskName "DailyInvoiceSummary" `
    -Action $action -Trigger $trigger `
    -Settings $settings -Principal $principal

Logging pattern

Scripts running without a session have no console. Write a structured log row on every run — success or failure.

powershell
$logRow = $null

try {
    # ... your script work ...
    $logRow = [PSCustomObject]@{
        Date   = Get-Date -f "yyyy-MM-dd"
        Result = "OK"
        Error  = ""
    }
}
catch {
    $logRow = [PSCustomObject]@{
        Date   = Get-Date -f "yyyy-MM-dd"
        Result = "FAIL"
        Error  = $_.Exception.Message
    }
}
finally {
    # Prepend so newest row is always at top
    if (Test-Path $logPath) {
        @($logRow) + (Import-Csv $logPath) | Export-Csv $logPath -NoTypeInformation
    } else {
        $logRow | Export-Csv $logPath -NoTypeInformation
    }
}