I faced then same problem, move thousand of heaps to a different filegroup.
The way i accomplished the task was CI index create and drop.
If you are fine with this technique below is the script.
It enumerates heaps in a filegroup and generate the script for index create and drop.
Run in the context of your database, possibile during maintenance window.
Review the generated script before run. Work on MSSQM 2014 and above.
Hope it helps.
DECLARE @old_FG SYSNAME = 'PRIMARY' -- where are the heaps
DECLARE @new_FG SYSNAME = 'USER_DATA' -- filegroup we want heaps to go
DECLARE @fakeCI_sql NVARCHAR(MAX) = 'CREATE CLUSTERED INDEX {1}
ON {2}.{3}({4} ASC)
WITH (PAD_INDEX=OFF, STATISTICS_NORECOMPUTE=OFF, SORT_IN_TEMPDB=OFF, DROP_EXISTING=OFF, ONLINE=OFF, ALLOW_ROW_LOCKS=ON, ALLOW_PAGE_LOCKS=ON)
ON {5};
GO
DROP INDEX {6} ON {7}.{8};
GO'
DECLARE @t TABLE(id INT IDENTITY NOT NULL PRIMARY KEY CLUSTERED, TableName SYSNAME, SchemaName SYSNAME,
IndexName SYSNAME NULL, IndexType NVARCHAR(128), ColumnName SYSNAME)
INSERT @t (TableName, SchemaName, IndexName, IndexType, ColumnName)
SELECT o.[name], sch.[name], i.[name], i.type_desc, col.ColumnName
FROM sys.indexes i
INNER JOIN sys.filegroups fg ON i.data_space_id = fg.data_space_id
INNER JOIN sys.all_objects o ON i.[object_id] = o.[object_id]
INNER JOIN sys.schemas sch on sch.schema_id = o.schema_id
INNER JOIN (
-- this join is just to retrieve the name of the fist column name of tables
SELECT t.[name] AS TableName, s.[name] AS SchemaName, c.[name] AS ColumnName
FROM sys.tables t
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
INNER JOIN sys.columns c on c.object_id = t.object_id
WHERE
c.column_id=1 -- first column
and t.is_ms_shipped = 0
) col ON col.TableName=o.[name] AND col.SchemaName=sch.[name]
WHERE i.data_space_id = fg.data_space_id
AND o.type = 'U' -- user table
and o.is_ms_shipped=0
AND i.type_desc IN ('HEAP') -- only heap table
AND fg.[name] = @old_FG -- in this filegroup
DECLARE @id INT
DECLARE @TableName SYSNAME, @SchemaName SYSNAME, @ColumnName SYSNAME
DECLARE @IndexName NVARCHAR(128), @IndexType NVARCHAR(128), @outp NVARCHAR(MAX)
WHILE (1=1)
BEGIN
SELECT TOP 1 @id=id, @tablename=TableName, @SchemaName=SchemaName, @ColumnName=ColumnName,
@IndexName = CONCAT('_CI_', @TableName,'_', @ColumnName), @IndexType=IndexType FROM @t
IF @@ROWCOUNT=0
BREAK;
SET @outp=@fakeCI_sql
SET @outp = replace(@outp,'{1}', QUOTENAME(@IndexName))
SET @outp = replace(@outp,'{2}', QUOTENAME(@SchemaName))
SET @outp = replace(@outp,'{3}', QUOTENAME(@TableName))
SET @outp = replace(@outp,'{4}', QUOTENAME(@ColumnName))
SET @outp = replace(@outp,'{5}', QUOTENAME(@new_FG))
SET @outp = replace(@outp,'{6}', QUOTENAME(@IndexName))
SET @outp = replace(@outp,'{7}', QUOTENAME(@SchemaName))
SET @outp = replace(@outp,'{8}', QUOTENAME(@TableName))
print @outp -- script out
DELETE FROM @t WHERE id=@id
END