Skip to content
Guide · IoT data pipelines

BACnet Readings to PostgreSQL: Data Pipeline Design

How to get readings from BACnet building automation devices into PostgreSQL for reporting, CAFM and CMMS use: what the collector does, when to use change-of-value subscriptions or polling, how to map device, object and property to a point, and how to keep units and status flags. The PostgreSQL schema and load queries were run on PostgreSQL 18.6; BACnet facts come from bacnet.org and, as a secondary technical reference, the OPC Foundation's OPC UA for BACnet specification (OPC 30030). ASHRAE 135 itself was not consulted directly.

Facts checked 27 October 2026 against the vendors' official documentation. Versions covered: BACnet as described by bacnet.org and by OPC 30030 v2.00.1 (secondary technical reference; ASHRAE 135 not consulted directly); PostgreSQL 18 (examples run on 18.6 in PGlite 0.5.11). Next review due April 2027. Products, licences and prices in this field change quickly; confirm on the vendor's site before you choose. No benchmarks are quoted unless the source and test setup are named.
Short answer
  • PostgreSQL never talks BACnet. A collector on the building network acts as a BACnet client (reading properties or subscribing to change-of-value notifications) and writes rows to the database; that collector is the part to design carefully.
  • Identify every reading by device instance, object type, object instance and property, and map that key to your own point_id in a table. The readings table then stays narrow and BMS renames do not touch history.
  • Change-of-value (COV) reporting sends a value when it changes by at least the COV increment, so data arrives irregularly; polling gives regular samples but costs network traffic. A pipeline can mix both, recorded per point.
  • Store two times. collected_at is when the collector received or read the value and is always set; event_time is when the value was logged or changed according to the source, and is filled only when the source supplies one (for example a trend log record). Using the collector's time for live reads is this page's documented design choice, not a BACnet rule.
  • Keep the units and the four status flags (in alarm, fault, overridden, out of service). Overridden and out-of-service points are useful exceptions to surface in a CAFM or CMMS.
  • Load through a staging table with INSERT ... SELECT ... ON CONFLICT DO NOTHING, report unmapped objects instead of dropping them silently, and alert on stale points.
How we know: BACnet facts are taken from bacnet.org (the website of the BACnet Committee, ASHRAE SSPC 135: About the BACnet Standard and the FAQ) and from OPC 30030 v2.00.1, the OPC Foundation's OPC UA for BACnet companion specification, used here as a secondary technical reference that restates BACnet terms, object types, properties, status flags and engineering units; both checked 27 October 2026. ANSI/ASHRAE Standard 135 itself was not consulted directly, so nothing on this page should be read as verified against it. Statements the two sources do not cover (whether present-value reads and COV notifications carry a measurement time, so that this design records the collector's own collection time for them; back-filling from trend logs and converting their local-time record timestamps to UTC, MS/TP polling capacity, how to pack the status flags in a database) are labelled on the page as design assumptions or unverified; device-level behaviour varies by product, so confirm details against the standard and each device's PICS. All SQL on this page was run on PostgreSQL 18.6 (PGlite 0.5.11) with the session time zone set to UTC, and the outputs shown are from that run. No BACnet devices, collectors or gateways were run, and no product is recommended.

Scope and reader

This guide is for integrators and developers who need BMS data outside the BMS: energy reports, comfort KPIs, fault detection, and context for maintenance work orders in a CAFM or CMMS. The building runs BACnet; the target is PostgreSQL. The guide covers the pipeline design and the database side: architecture, acquisition choices, the mapping from BACnet identifiers to rows, units and status flags, and the SQL that loads and checks the data.

It is not a BACnet tutorial and does not cover writing to devices. The storage design behind the readings table (partitions, roll-ups, retention) is in How to Store IoT Sensor Data in a Database; querying the irregular data that COV produces is in Querying Irregularly Sampled Sensor Data.

What the pipeline needs to know about BACnet

BACnet is the data communication protocol standard for building automation and control networks, maintained by ASHRAE's standing committee SSPC 135 and published as ANSI/ASHRAE Standard 135 and as ISO 16484-5.

Sources used for BACnet details. ASHRAE 135 is a purchased standard and was not consulted directly for this page. The BACnet details below come from bacnet.org and from OPC 30030, the OPC Foundation's OPC UA for BACnet specification, which is used here as a secondary technical reference: it restates BACnet definitions in order to map them to OPC UA. Where neither source covers a point, the page says so and labels the statement as a design assumption. Four parts of BACnet matter for a data pipeline:

  • Objects. Devices expose their data as objects: analog, binary and multi-state inputs, outputs and values, plus device, schedule, trend log, alarm and other object types. A zone temperature is typically an analog input; a fan status a binary input.
  • Properties. Each object has properties: its name and description, the present value, units, alarm limits and, for analogue objects, a status flags property and a COV increment. A pipeline mostly reads the present value and status flags, and reads the name, description and units once for the point register.
  • Services. Application services read and write properties and deliver change-of-value notifications; other services cover alarms, files and device management. A data collector needs only the read and COV services.
  • Networks. BACnet runs over several data links. bacnet.org describes MS/TP as a twisted-pair technology designed for building control applications. The OPC 30030 summary (secondary reference) describes BACnet/IP over UDP and MS/TP over EIA-485 serial networks as commonly used, and BACnet/SecureConnect, based on TCP and WebSockets, as rarely used but expected to become common.

bacnet.org states that devices may implement only the parts of the standard they need, and that every BACnet device is required to have a Protocol Implementation Conformance Statement (PICS); OPC 30030 describes the PICS as the vendor's self-declaration of which parts of BACnet a device supports. Support for COV and for the various read services therefore varies by device: check its PICS before choosing COV or polling.

Architecture: collector, buffer, loader, database

The data flow has five stages, each with one job:

  1. Devices on BACnet/IP, or on MS/TP trunks reached through BACnet routers.
  2. Collector on the building network: a BACnet client that discovers devices, reads the object list and properties, subscribes to COV or polls on a schedule, records when it received each value (and the source's own timestamp where the source supplies one, as trend log records do), and writes it to a local buffer. It is read-only towards the devices.
  3. Buffer: a local file or embedded queue on the collector host, or a message broker (MQTT or Kafka) if several consumers need the stream. It lets the collector keep working while the database or the WAN link is down. A broker moves data; it is not the store of record.
  4. Loader: writes batches into a staging table and runs the set-based load into the readings table.
  5. PostgreSQL: the store of record for readings and the point register, read by dashboards and by CAFM/CMMS integrations. Long-term analytics across many sites belongs in a warehouse or lakehouse fed from here.

bacnet.org distinguishes a router, which passes BACnet messages between BACnet networks unchanged, from a gateway, which translates between BACnet and another protocol such as Modbus. In this design the collector is neither: it is a BACnet client. If some plant speaks Modbus, a BACnet gateway in front of it makes it look like BACnet to the collector, at the configuration and maintenance cost bacnet.org notes for gateways.

When the collector publishes to MQTT rather than writing directly, the database side is the same as in MQTT to TimescaleDB: Ingestion Architecture. For the difference between brokers and logs, see Kafka vs MQTT.

Change of value or polling

BACnet change-of-value reporting informs a subscribed client when a value changes, without polling; for analogue objects the COV increment property sets the minimum change in present value that triggers a notification (both as restated in OPC 30030). Polling reads the present value on a fixed schedule. They produce different data, and the database design has to know which one each point uses.

COV and polling compared for a data pipeline (design comparison; rows marked as design assumptions are not from the sources)
AspectCOV subscriptionPolling
Data shapeIrregular: bursts during change, silence when steadyRegular samples at the poll interval
Network load (design assumption)Low when values are steadyGrows with points times poll rate; a particular concern on MS/TP, which is a serial EIA-485 network
Missed changesChanges smaller than the COV increment are not reportedShort excursions between polls are missed
Silence means (design assumption)No change, or a lost subscription or link; the collector cannot tell which without a periodic re-readA failure (no response)
Device supportVaries by device and object; check the PICSReading properties is one of the object access services; check the PICS for which read services the device supports
Typical uses (judgement)Setpoints, states, slowly varying temperatures, alarm contextEnergy meters, values feeding fixed-interval reports

Timestamps: collection time and event time

A reading can carry two different times, and the data model on this page keeps them apart:

  • Collection time (collected_at): when the collector received a COV notification or got the answer to a read. The collector always knows it, so the column is always set. Run the collector host on NTP-synchronised time and store it as timestamptz.
  • Event time (event_time): when the value was measured, changed or logged according to the source itself. It is set only when the source supplies one, and is otherwise NULL.

What the sources say, and what they do not. OPC 30030 (secondary reference) describes trend log objects as logging a monitored property's value and timestamp into an internal buffer, and its log object type as serialising the timestamp as local time; its annex on time synchronisation states that BACnet devices use local time for timestamps. It also maps a Time Stamp field in BACnet event (alarm) notifications, noting that a device may send a sequence number there instead of a time. Neither bacnet.org nor OPC 30030, as consulted, describes a measurement timestamp in a present-value read or a COV notification. Recording the collector's collection time for those values is therefore this page's documented design choice, not a universal BACnet rule: the standard or a particular device may provide more, so check ASHRAE 135 and each device's PICS, and fill event_time wherever a source does provide a time.

Back-fill from trend logs. As a design option, a collector can read trend log records after an outage to fill gaps with the device's own timestamps. Those records get event_time from the record (converted from the device's local time to UTC) and collected_at from the moment the collector read them. Whether a device has trend logs, how much they hold and how they are read is device-specific (check the PICS). The record format, how a device marks its UTC offset, and the hour that repeats when clocks go back were not checked against ASHRAE 135; a local time in that hour is ambiguous unless the device supplies an offset.

Which time should a query use? Use collected_at for collector and network health (staleness, gaps in collection, load latency). Use coalesce(event_time, collected_at) as the observation time for trends, averages and reports, and remember that for live COV and polled values it is the collection time, so it includes network and collector delay. Where the difference matters, report collected_at - event_time, which also shows how late back-filled data arrived.

Data model: mapping BACnet identifiers to points

A BACnet value is identified by four things: the device instance (unique on the BACnet internetwork), the object type, the object instance within the device, and the property. Store that key once, in a mapping table, against your own point_id. Readings then carry only point_id, the two times described above (collected_at and the nullable event_time), the value and the packed status flags. Because back-filled trend records read in one request share a collection time, the table uses a surrogate key and two partial unique indexes as its duplicate rules: one collection per point and collection time for live values (no event time), and one record per point and event time for values that carry a source timestamp.

  • Units. BACnet reports units as an enumeration. Store the enumeration value from the device and a readable unit in the point register. The units mapping table in OPC 30030 (secondary reference; the values are quoted in the evidence below) lists, for example, 62 for degree Celsius, 96 for parts per million, 48 for kilowatt, 19 for kilowatt hour and 95 for no units. Check the values against the device's own documentation before hard-coding them. Convert units in the loader only if you must; otherwise convert in views so raw data stays as delivered.
  • Status flags. OPC 30030 (secondary reference) describes four boolean status flags on analogue objects (in alarm, fault, overridden and out of service) and maps a status flags property on its binary and multi-state object types as well; its BACnetStatusFlags data type numbers them as bits 0 to 3 in that order. Packing them into a smallint with the same bit numbers is this page's storage choice, not a BACnet encoding; separate boolean columns work just as well. OPC 30030 also states that while out of service is true the present value may be changed for simulation or testing, so treat such readings as not measured.
  • Binary and multi-state values. Store binary present values as 0 and 1 and multi-state values as their integer state number, with the state texts in a lookup table per point.
  • Names. Object names and descriptions are configured by the integrator and change. Keep them in the point register for search, never as the key.
Device, point register, BACnet mapping and readings (PostgreSQL, run on 18.6)
SET TimeZone = 'UTC';

CREATE TABLE bacnet_device (
    device_instance  integer PRIMARY KEY,
    site_code        text NOT NULL,
    network_address  text NOT NULL,
    site_time_zone   text NOT NULL,    -- used to convert device local-time records to UTC
    description      text
);

CREATE TABLE point (
    point_id     integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    asset_tag    text NOT NULL,
    point_name   text NOT NULL,
    unit         text NOT NULL,
    UNIQUE (asset_tag, point_name)
);

CREATE TABLE bacnet_point_map (
    point_id         integer PRIMARY KEY REFERENCES point (point_id),
    device_instance  integer NOT NULL REFERENCES bacnet_device (device_instance),
    object_type      text NOT NULL CHECK (object_type IN ('analog-input', 'analog-value', 'binary-input', 'binary-value', 'multi-state-input')),
    object_instance  integer NOT NULL,
    property         text NOT NULL DEFAULT 'present-value',
    bacnet_units     integer,
    acquisition      text NOT NULL CHECK (acquisition IN ('cov', 'poll')),
    poll_interval_s  integer,
    UNIQUE (device_instance, object_type, object_instance, property)
);

CREATE TABLE reading (
    reading_id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    point_id      integer NOT NULL REFERENCES point (point_id),
    collected_at  timestamptz NOT NULL,   -- when the collector received or read the value (always set)
    event_time    timestamptz,            -- when the source says it happened; NULL unless the source supplies it
    value         double precision,
    status_flags  smallint NOT NULL DEFAULT 0,
    source        text NOT NULL CHECK (source IN ('cov', 'poll', 'trend-log'))
);
-- duplicate rules: live values by collection time, source-timestamped values by event time
CREATE UNIQUE INDEX reading_collected_uq ON reading (point_id, collected_at) WHERE event_time IS NULL;
CREATE UNIQUE INDEX reading_event_uq     ON reading (point_id, event_time)   WHERE event_time IS NOT NULL;

INSERT INTO bacnet_device VALUES
    (1001, 'HQ', '10.20.1.15', 'Europe/London', 'AHU-01 controller'),
    (1002, 'HQ', '10.20.1.16', 'Europe/London', 'CH-01 plant controller');

INSERT INTO point (asset_tag, point_name, unit) VALUES
    ('AHU-01', 'supply_air_temp', 'degC'),
    ('AHU-01', 'zone_co2',        'ppm'),
    ('AHU-01', 'supply_fan_status', 'state'),
    ('CH-01',  'power_kw',        'kW');

INSERT INTO bacnet_point_map (point_id, device_instance, object_type, object_instance, bacnet_units, acquisition, poll_interval_s) VALUES
    (1, 1001, 'analog-input', 1,  62, 'cov',  NULL),
    (2, 1001, 'analog-input', 7,  96, 'poll', 60),
    (3, 1001, 'binary-input', 3,  NULL, 'cov', NULL),
    (4, 1002, 'analog-input', 12, 48, 'poll', 60);

Loading: staging, mapping and duplicates

The collector (or the loader reading its buffer) writes what it received, in BACnet terms, to a staging table. A set-based statement then joins staging to the mapping, converts any device local time to UTC with the site time zone, packs the flags and inserts into reading. ON CONFLICT DO NOTHING, written without a conflict target so that both partial unique indexes act as arbiters, discards repeats, such as a value delivered twice after a collector restart or a trend record read twice; without it a repeat fails with a unique violation (SQLSTATE 23505). Live COV and polled values arrive with no source time, so their event_local is NULL. The sample below includes one exact duplicate and one object that nobody has mapped yet.

Staging rows as the collector delivered them (run on 18.6)
CREATE TABLE bacnet_raw (
    collected_at     timestamptz NOT NULL,   -- collector receive or read time
    event_local      timestamp,              -- source timestamp in device local time, if the source supplied one
    device_instance  integer NOT NULL,
    object_type      text NOT NULL,
    object_instance  integer NOT NULL,
    property         text NOT NULL,
    value            double precision,
    in_alarm         boolean NOT NULL,
    fault            boolean NOT NULL,
    overridden       boolean NOT NULL,
    out_of_service   boolean NOT NULL,
    source           text NOT NULL
);

INSERT INTO bacnet_raw VALUES
    ('2026-10-05 08:00:00+00', NULL, 1001, 'analog-input', 1,  'present-value', 13.2, false, false, false, false, 'cov'),
    ('2026-10-05 08:00:00+00', NULL, 1001, 'analog-input', 7,  'present-value', 612,  false, false, false, false, 'poll'),
    ('2026-10-05 08:00:00+00', NULL, 1002, 'analog-input', 12, 'present-value', 184.5, false, false, false, false, 'poll'),
    ('2026-10-05 08:01:00+00', NULL, 1001, 'analog-input', 7,  'present-value', 618,  false, false, false, false, 'poll'),
    ('2026-10-05 08:01:00+00', NULL, 1002, 'analog-input', 12, 'present-value', 0,    false, false, true,  false, 'poll'),
    ('2026-10-05 08:01:00+00', NULL, 1002, 'analog-input', 12, 'present-value', 0,    false, false, true,  false, 'poll'),
    ('2026-10-05 08:01:20+00', NULL, 1001, 'analog-input', 1,  'present-value', 13.9, false, false, false, false, 'cov'),
    ('2026-10-05 08:01:40+00', NULL, 1001, 'binary-input', 3,  'present-value', 0,    true,  false, false, false, 'cov'),
    ('2026-10-05 08:02:00+00', NULL, 1001, 'analog-input', 7,  'present-value', 624,  false, false, false, false, 'poll'),
    ('2026-10-05 08:02:00+00', NULL, 1001, 'analog-input', 9,  'present-value', 41.0, false, false, false, false, 'poll'),
    ('2026-10-05 08:03:00+00', NULL, 1001, 'analog-input', 7,  'present-value', 631,  false, false, false, false, 'poll'),
    ('2026-10-05 08:04:00+00', NULL, 1001, 'analog-input', 7,  'present-value', 640,  false, false, false, false, 'poll');
Map, convert source times, pack the flags and load (run on 18.6)
-- storage choice: flags packed with in_alarm = bit 0, fault = bit 1, overridden = bit 2, out_of_service = bit 3
INSERT INTO reading (point_id, collected_at, event_time, value, status_flags, source)
SELECT m.point_id,
       r.collected_at,
       r.event_local AT TIME ZONE d.site_time_zone,   -- NULL stays NULL
       r.value,
       (r.in_alarm::int | (r.fault::int << 1) | (r.overridden::int << 2) | (r.out_of_service::int << 3))::smallint,
       r.source
FROM bacnet_raw r
JOIN bacnet_point_map m
  ON  m.device_instance = r.device_instance
  AND m.object_type     = r.object_type
  AND m.object_instance = r.object_instance
  AND m.property        = r.property
JOIN bacnet_device d ON d.device_instance = r.device_instance
ON CONFLICT DO NOTHING;

SELECT count(*) AS raw_rows FROM bacnet_raw;      -- 12
SELECT count(*) AS loaded_rows FROM reading;      -- 10
Unmapped objects: data arriving that no point uses (run on 18.6)
SELECT r.device_instance, r.object_type, r.object_instance, count(*) AS messages
FROM bacnet_raw r
LEFT JOIN bacnet_point_map m
  ON  m.device_instance = r.device_instance
  AND m.object_type     = r.object_type
  AND m.object_instance = r.object_instance
  AND m.property        = r.property
WHERE m.point_id IS NULL
GROUP BY 1, 2, 3;

--  device_instance | object_type  | object_instance | messages
-- -----------------+--------------+-----------------+----------
--             1001 | analog-input |               9 |        1

Why 12 rows became 10

One row was the duplicate chiller reading collected at 08:01, skipped by ON CONFLICT through the collection-time index; one was analog input 9 on device 1001, which has no mapping. Report the second kind to the integrator daily rather than discarding it silently: it is usually a point someone added to the BMS and forgot to register.

Status flags and staleness: queries maintenance teams use

Decoded flags turn the readings table into a list of exceptions. An overridden chiller power reading or a fan status in alarm is a candidate for a CMMS work order or at least a check by the engineer. Staleness is the other half: a polled point that has not delivered for three intervals means a collector, network or device fault. COV points need a different rule, because silence is normal for them; the example only reports their age.

Readings with any status flag set (run on 18.6)
SELECT p.asset_tag, p.point_name, r.collected_at::text AS collected_at, r.value,
       (r.status_flags & 1) <> 0 AS in_alarm,
       (r.status_flags & 2) <> 0 AS fault,
       (r.status_flags & 4) <> 0 AS overridden,
       (r.status_flags & 8) <> 0 AS out_of_service
FROM reading r JOIN point p USING (point_id)
WHERE r.status_flags <> 0
ORDER BY r.collected_at, p.point_id;

--  asset_tag |    point_name     |      collected_at      | value | in_alarm | fault | overridden | out_of_service
-- -----------+-------------------+------------------------+-------+----------+-------+------------+----------------
--  CH-01     | power_kw          | 2026-10-05 08:01:00+00 |     0 | false    | false | true       | false
--  AHU-01    | supply_fan_status | 2026-10-05 08:01:40+00 |     0 | true     | false | false      | false
Staleness at 08:05, by collection time: polled points silent for more than three intervals (run on 18.6)
SELECT p.asset_tag, p.point_name, m.acquisition,
       max(r.collected_at)::text AS last_collected,
       (timestamptz '2026-10-05 08:05:00+00' - max(r.collected_at))::text AS age,
       CASE WHEN m.acquisition = 'poll'
             AND timestamptz '2026-10-05 08:05:00+00' - max(r.collected_at) > m.poll_interval_s * 3 * interval '1 second'
            THEN 'stale' ELSE 'ok' END AS status
FROM bacnet_point_map m
JOIN point p USING (point_id)
LEFT JOIN reading r USING (point_id)
GROUP BY p.point_id, p.asset_tag, p.point_name, m.acquisition, m.poll_interval_s
ORDER BY p.point_id;

--  asset_tag |    point_name     | acquisition |     last_collected     |   age    | status
-- -----------+-------------------+-------------+------------------------+----------+--------
--  AHU-01    | supply_air_temp   | cov         | 2026-10-05 08:01:20+00 | 00:03:40 | ok
--  AHU-01    | zone_co2          | poll        | 2026-10-05 08:04:00+00 | 00:01:00 | ok
--  AHU-01    | supply_fan_status | cov         | 2026-10-05 08:01:40+00 | 00:03:20 | ok
--  CH-01     | power_kw          | poll        | 2026-10-05 08:01:00+00 | 00:04:00 | stale

After the outage: back-fill from a trend log and choose the time column

Continuing the same run: the chiller point went stale because polling failed. Assume (a design option, see the timestamps section) that the CH-01 controller keeps a trend log of that point and the collector reads it at 08:06 UTC and again at 08:07, with overlapping records. The record times are in the device's local time, which on 5 October 2026 in the Europe/London zone is UTC+1, so 09:02 local is 08:02 UTC. Each staged record keeps the monitored object's identifiers so the same mapping applies. The load statement is the one above; only new rows are inserted.

The listing then shows why both columns are needed: collected_at tells you the data reached the database four minutes late, and event_time puts each value where it belongs on the time axis. A report would order and bucket by the observation time, coalesce(event_time, collected_at); a collector health check would keep using collected_at.

Stage two overlapping trend log reads (record times in device local time) and load them (run on 18.6)
INSERT INTO bacnet_raw VALUES
    ('2026-10-05 08:06:00+00', '2026-10-05 09:02:00', 1002, 'analog-input', 12, 'present-value', 186.0, false, false, false, false, 'trend-log'),
    ('2026-10-05 08:06:00+00', '2026-10-05 09:03:00', 1002, 'analog-input', 12, 'present-value', 188.5, false, false, false, false, 'trend-log'),
    ('2026-10-05 08:06:00+00', '2026-10-05 09:04:00', 1002, 'analog-input', 12, 'present-value', 190.0, false, false, false, false, 'trend-log'),
    ('2026-10-05 08:07:00+00', '2026-10-05 09:03:00', 1002, 'analog-input', 12, 'present-value', 188.5, false, false, false, false, 'trend-log'),
    ('2026-10-05 08:07:00+00', '2026-10-05 09:04:00', 1002, 'analog-input', 12, 'present-value', 190.0, false, false, false, false, 'trend-log'),
    ('2026-10-05 08:07:00+00', '2026-10-05 09:05:00', 1002, 'analog-input', 12, 'present-value', 191.5, false, false, false, false, 'trend-log');

-- same load statement as above, wrapped to count the rows it inserted
WITH ins AS (
    INSERT INTO reading (point_id, collected_at, event_time, value, status_flags, source)
    SELECT m.point_id, r.collected_at, r.event_local AT TIME ZONE d.site_time_zone, r.value,
           (r.in_alarm::int | (r.fault::int << 1) | (r.overridden::int << 2) | (r.out_of_service::int << 3))::smallint,
           r.source
    FROM bacnet_raw r
    JOIN bacnet_point_map m
      ON  m.device_instance = r.device_instance
      AND m.object_type     = r.object_type
      AND m.object_instance = r.object_instance
      AND m.property        = r.property
    JOIN bacnet_device d ON d.device_instance = r.device_instance
    ON CONFLICT DO NOTHING
    RETURNING 1
)
SELECT count(*) AS rows_loaded FROM ins;

--  rows_loaded
-- -------------
--            4
Both times for the chiller point, ordered by observation time (run on 18.6)
SELECT r.source,
       r.collected_at::text                         AS collected_at,
       r.event_time::text                           AS event_time,
       coalesce(r.event_time, r.collected_at)::text AS observed_at,
       (r.collected_at - r.event_time)::text        AS delay,
       r.value
FROM reading r
WHERE r.point_id = 4
ORDER BY coalesce(r.event_time, r.collected_at), r.collected_at;

--   source   |      collected_at      |       event_time       |      observed_at       |  delay   | value
-- -----------+------------------------+------------------------+------------------------+----------+-------
--  poll      | 2026-10-05 08:00:00+00 |                        | 2026-10-05 08:00:00+00 |          | 184.5
--  poll      | 2026-10-05 08:01:00+00 |                        | 2026-10-05 08:01:00+00 |          |     0
--  trend-log | 2026-10-05 08:06:00+00 | 2026-10-05 08:02:00+00 | 2026-10-05 08:02:00+00 | 00:04:00 |   186
--  trend-log | 2026-10-05 08:06:00+00 | 2026-10-05 08:03:00+00 | 2026-10-05 08:03:00+00 | 00:03:00 | 188.5
--  trend-log | 2026-10-05 08:06:00+00 | 2026-10-05 08:04:00+00 | 2026-10-05 08:04:00+00 | 00:02:00 |   190
--  trend-log | 2026-10-05 08:07:00+00 | 2026-10-05 08:05:00+00 | 2026-10-05 08:05:00+00 | 00:02:00 | 191.5

Why 6 staged records became 4 rows

The second read repeated the 09:03 and 09:04 local records. They have an event time, so the event-time index rejected them, while the live rows already loaded were skipped through the collection-time index. One limit of this model, as a design note: a live poll and a trend record for the same instant are stored as two rows (one with and one without an event time). If both sources are active for a point, decide which wins in queries, for example by preferring rows with an event time inside the back-filled window.

Sizing, security and operations

Sizing. Rows per day are the sum over points of 86,400 divided by the poll interval, plus COV notifications, which depend on how often values cross their COV increment. Measure the COV rate per point type over a representative week before sizing storage; then apply the per-row estimate in How to Store IoT Sensor Data. MS/TP runs over EIA-485 serial networks (OPC 30030, secondary reference). The sources used for this page give no capacity figures, so treat the polling budget on an MS/TP trunk as a design assumption to confirm with the integrator (baud rate, number of devices, existing BMS traffic), and poll only the points that need regular samples.

Security. Keep the collector on the building network and let it make outbound connections only to the buffer or database; do not route BACnet traffic to the corporate network or the internet. Give the collector a BACnet configuration that only reads and subscribes, and a database role that can insert into the staging table and nothing else. OPC 30030 (secondary reference) describes BACnet/SecureConnect as rarely used but expected to become common; if a site adopts it, the collector has to support that data link to reach those devices. Its security properties were not checked for this page.

Operations. Re-read the device object list on a schedule and compare it with the mapping, so new, renamed and deleted objects are found. Track per-device last contact, rows loaded, rows rejected and unmapped objects. Keep the point register under change control together with the BMS as-built documentation: it is the link between a database row and a physical sensor, and between the BMS and the asset register in the CAFM or CMMS.

When not to build this. If the BMS already exports trend data on a schedule (CSV or an API) and you need only daily reports, import that instead of running a collector. If the BMS supervisor offers OPC UA or MQTT northbound, consuming that may be simpler than speaking BACnet directly. And if you need to control plant, that is a BMS function: this pipeline reads only. For choosing the database engine itself, see the time-series database selection guide and the time-series overview.

Frequently asked questions

Can PostgreSQL read BACnet directly?

No. PostgreSQL has no BACnet support; a separate collector acts as a BACnet client and writes the values it reads into PostgreSQL, directly or through a buffer such as MQTT or Kafka.

Should I use BACnet COV or polling for data collection?

Use COV where devices support it and values change slowly or in steps, and polling where you need regular samples, such as energy meters. Many sites use both, recorded per point so queries know how to treat the data.

How do I identify a BACnet point uniquely in a database?

By device instance, object type, object instance and property. Store that combination in a mapping table with a unique constraint and refer to it by your own point_id.

What are BACnet status flags?

Four booleans on analogue, binary and multi-state objects: in alarm, fault, overridden and out of service (as restated in OPC 30030, used here as a secondary reference). Store them with each reading; they show when a value is not a normal measurement.

Do BACnet readings include a timestamp?

Some do. OPC 30030 (secondary reference) describes trend log records as carrying a timestamp, in device local time, and BACnet event notifications as having a Time Stamp field. The sources used for this page do not describe a measurement time in present-value reads or COV notifications, so this design records the collector's collection time for them as a documented design choice, not a BACnet rule. Store both: collected_at always, and event_time only when the source supplies one; confirm what your devices provide against ASHRAE 135 and their PICS.

Should queries use collection time or event time?

Use collection time for collector and network health checks such as staleness. Use coalesce(event_time, collected_at) as the observation time for trends and reports, knowing that for live values it includes collection delay, and report collected_at - event_time where lateness matters.

Sources

Checked 27 October 2026.

How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.

Building a sensor or telemetry database?

Start with the overview, or compare time-series databases side by side.